前阵子有个朋友问我,能不能给老家院子里的摄像头加个功能:一有人进院子,手机就收到提醒。这需求听着很玄乎,但拆开来看,核心就一件事——用Python和OpenCV把画面里“动起来”的区域找出来。运动物体检测在安防监控、无人值守、流量统计、车间安全告警这些场景里都特别常见,属于OpenCV图像处理项目里最经典、也最容易上手的一类。
这篇文章我会把运动物体检测的完整思路讲透:从三种主流方案怎么选,到背景建模的底层原理,再到一套可以直接跑起来的完整代码,最后把我这两年在实际项目里踩过的坑、调参的经验全部摊开来讲。无论你是刚装好OpenCV还没跑通第一个demo的新手,还是已经写过不少OpenCV代码但总感觉检测效果不稳的老手,这篇都适合你花二十分钟从头读到尾,该抄的作业我会放在明面上。
1. 三种主流方案怎么选:帧差法、背景建模和光流法对比
很多人一上来就搜“OpenCV运动物体检测”,然后就搜到一堆互相矛盾的资料。有人用absdiff做帧差,有人用createBackgroundSubtractorMOG2,还有人讲光流法,到底学哪个?这个问题的答案取决于你的使用场景。我先花点篇幅把这三条路线讲清楚,因为这决定了你后续所有代码的选型方向。
帧差法(Frame Differencing)是最朴素的一种思路:取当前帧和上一帧,逐像素做差,差值超过一定阈值的像素就认为是“变化的区域”。它的优点极度明显——代码量小,计算量小,几行就能跑起来,对背景缓慢变化也不太敏感。我最早接触运动检测就是从cv2.absdiff开始的,确实能在固定摄像头画面里检测出手臂挥动这类明显的运动。但帧差法有个致命问题:它对运动物体内部颜色均匀的区域会失效。想象一辆纯白色的车从画面里开过去,车身中间区域和上一帧背景都是浅色的,像素差太小,检测出来的就是一坨破破烂烂的轮廓,中间全是空洞,业内管这叫“空洞现象”,另外快速运动的物体会在前后两帧的位置产生“双影”。这两个问题在后续做轮廓分析和目标框选时会被无限放大。
背景减除法(Background Subtraction)是工程上用得最多的方案。它和帧差法的核心区别在于:帧差法只拿前后两帧比,而背景减除法会先“学习”一段时间的画面,建立一张背景模型,然后用当前帧减去背景模型,剩下的就是前景。听起来差不多,但背景建模高明在它允许背景本身是动态的——比如树叶在风中晃动、水面波纹、显示器屏幕闪烁,这些轻微变化如果出现在背景模型里,模型会逐渐“习惯”它们,不会把它们当成运动目标。OpenCV里有两个现成实现:createBackgroundSubtractorMOG2和createBackgroundSubtractorKNN,背后分别是混合高斯模型(Mixture of Gaussians)和K近邻算法。这个方案对固定摄像头的监控场景是最优解,也是本文接下来要展开的重点。
光流法(Optical Flow)走的是另一条技术路线:它不关心“哪个区域变了”,而是跟踪画面里每个特征点的移动方向和速度。稀疏光流(如Lucas-Kanade算法)和稠密光流(如Farneback算法)都能提供比“有没有动”更丰富的信息——目标往哪个方向走、速度多快。光流法的优势是能拿到运动矢量,可以做轨迹追踪和行为分析,但代价是计算量明显更大,而且对画面纹理要求高——一面纯色墙壁前的目标是提取不到有效特征点的。实时视频流做稠密光流,CPU占用率很容易直接拉满。
给你一个非常直接的选型结论:做安防监控、区域入侵告警、人流统计这类“固定摄像头下想知道有没有东西进入画面”的需求,优先选背景减除法;做开发板、低算力设备上的极简检测,或者只是临时验证一下思路,可以用帧差法;做运动目标的轨迹分析、方向判断,或者目标运动速度建模,才需要上光流法。深度学习目标检测(如YOLO)是另一个维度,它识别的是“物体的外观”,和运动检测识别“物体在动”是两套逻辑,别混为一谈。
2. 环境与视频源准备:跑通OpenCV之前先把这些搞定
环境问题本不该占太多篇幅,但根据我观察到的现象,半数以上的人卡在第一周根本不是在学算法,而是在折腾opencv的安装。这里我把高频问题和标准解法一次性说清楚。
安装方面,Python生态里有两个容易混淆的包:opencv-python和opencv-contrib-python。前者是OpenCV的主模块,后者在主模块基础上额外包含了contrib扩展模块,比如SIFT、SURF这些专利算法、cv2.dnn里的部分模型、以及一些相对冷门的工具函数。我的建议是直接装opencv-contrib-python,省得以后用到某个扩展功能时再来补装,版本冲突的坑没必要踩。pip install opencv-contrib-python装完后,在Python里执行import cv2; print(cv2.__version__),如果能看到类似4.9.0的版本号,就说明装好了。
顺便解决一个很多人百思不得其解的问题:Anaconda的Prompt里面import cv2报ModuleNotFoundError: No module named 'cv2'。这大概率是你在Anaconda的base环境里根本没装opencv,或者装了但装进了另一个虚拟环境。建议你养成给每个项目建独立虚拟环境的习惯,不要一直在base环境里裸奔。比如用Anaconda,执行conda create -n cv python=3.9建环境,然后conda activate cv,再pip install opencv-contrib-python。这样哪怕环境装坏了,删掉重建也就一两分钟的事。
视频源这块是很多人会忽略但非常影响后续体验的环节。运动检测的输入无非三种:摄像头、视频文件、RTSP网络流。用摄像头时,cv2.VideoCapture(0)里面的数字代表设备编号,从0开始。笔记本自带摄像头通常是0,外接USB摄像头可能是1也可能是2,多试几次就知道。Linux下可以用ls /dev/video*查看所有视频设备,Windows下可以在设备管理器里看成像设备列表。这里有一个我踩过的坑:某些USB摄像头在OpenCV里默认分辨率很低(640x480),画面模糊导致检测效果差,你可以在打开设备后设置:
cap = cv2.VideoCapture(0) cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1280) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 720) cap.set(cv2.CAP_PROP_FPS, 30)注意,摄像头不一定支持所有分辨率,设置了也不一定生效。保险的做法是设置完后再读一遍实际值,确认落到了多少。
RTSP网络流是另一个高频需求。很多摄像头(尤其是海康、大华的IP Camera)都支持RTSP协议推流,这样不需要USB连接也能做实时检测。典型的RTSP地址形如rtsp://用户名:密码@IP地址:554/Streaming/Channels/101。在OpenCV里使用方式完全一样,把地址字符串传给cv2.VideoCapture即可。但RTSP流有一个比较突出的问题:网络稍有波动,拉流就中断,cap.read()会一直返回False。热搜词里“opencv python拉流中断”这个高频词说明很多人被这个问题折磨过。后面我会在避坑章节给出一个靠谱的重连方案,这里先记住一个判断逻辑:每次cap.read()之后先判断返回值,不要直接处理图像,否则一断流程序就崩溃。
3. 核心原理拆解:背景模型如何学会“什么是静止”
直接用cv2.createBackgroundSubtractorMOG2写代码很容易,但要调好参数、知道它为什么这么表现,还是得把原理吃透。MOG2全称是“Improved Adaptive Gaussian Mixture Model for Background Subtraction”,由Zivkovic在2004年提出,是经典混合高斯背景建模的改进版本。
背景建模的核心问题可以这样描述:摄像头固定不动时,画面里每个像素点在时间轴上会不断出现不同的颜色值。假设你正对一条走廊拍摄,走廊的墙面像素大部分时间都是白色的,偶尔有人经过时这个像素会短暂变成深色。如果每个像素都能维护一个“颜色取值模型”,在检测时把当前颜色和这个模型比对,颜色在模型范围内就判定为背景,超出范围就判定为前景,那问题就解决了。
混合高斯模型的具体做法是:让每个像素点同时用多个高斯分布来描述颜色分布。为什么需要多个而不是一个?因为背景本身可能是多模态的。举个例子,一个像素在静止时是暗色的,当有人在附近走动挡住光源时,这个像素颜色会周期性变化;摄像头前的树叶被风吹动,某个像素一会儿是绿色(树叶),一会儿是浅色(天空/墙面),这种像素的颜色分布根本无法用一个高斯分布表达。MOG2会为每个像素动态维护K个高斯分布(K的范围通常给到3到5),每个分布都有自己的权重、均值和方差。每个新帧到来时,像素颜色会和已有的K个高斯分布逐个匹配,匹配上了就更新对应分布的均值和方差,并提高它的权重;没匹配上就新建一个分布,把最不重要的旧分布淘汰掉。
MOG2相比早期版本的改进在于:它不再强制所有像素统一使用相同数量的高斯分布,而是根据每个像素颜色变化的复杂程度,自动决定用几个分量来建模。静止不动的墙面可能一个高斯分布就够,而树叶区域的像素可能需要三四个分布才能稳定描述。这种自适应机制节省了计算量,也提高了动态背景下的抗干扰能力。
这里要引出createBackgroundSubtractorMOG2两个最重要的初始化参数。第一个是history,它决定了背景模型“记忆”多少帧。默认是500帧,按25fps算相当于20秒的记忆窗口。history越大,模型适应场景变化越慢,但背景模型更稳定;history越小,模型适应变化越快,但可能把缓慢运动的物体误吸收为背景。第二个是varThreshold,它决定了像素颜色偏离背景模型多远才被判为前景。这个值越小,检测越敏感,但噪声也越多;越大,检测越迟钝,但能滤掉不少轻微抖动。默认是16,实际项目中我一般从20左右开始调。这两个参数没有绝对的对错,取决于具体场景的对比度和噪声水平。
KNN背景分割器(createBackgroundSubtractorKNN)的思路和MOG2不太一样,它不是用参数化分布来建模,而是用最近K个背景样本直接判断当前像素是否属于背景。OpenCV的KNN实现是经典算法的高效变体,它不需要维护复杂的高斯模型,但效果在多数场景下和MOG2相当,在某些动态背景较多的情况下甚至更优。不过实测下来,MOG2在光照渐变场景的稳定性更好,所以我的默认选择始终是MOG2。
还有一点容易被忽视:背景模型学到的是“静止”,不是“不存在”。如果一辆车开进画面后在镜头前停了三分钟,背景模型会逐渐把这辆车“融入”背景,三分钟后车辆会从前景掩码里消失。这不是bug,是背景建模的正常特性。应对方案是:如果需要检测“画面中是否有异物停留”,就不能只依赖背景减除,还要配合静止目标检测逻辑,这里先不展开,放到进阶章节讲。
4. 一个可直接复用的运动检测脚本
理论铺垫够了,现在进入实操。下面这个脚本是我在多个项目里反复用过的基础版本,它把运动检测的主要步骤完整串起来:初始化视频源、建立背景模型、逐帧处理、前景掩码后处理、轮廓提取、目标框绘制。代码可以直接复制保存为motion_detector.py运行。
import cv2 import numpy as np def main(): # 视频源:摄像头、视频文件路径或RTSP地址 source = 0 cap = cv2.VideoCapture(source) if not cap.isOpened(): print("无法打开视频源") return # 降低处理分辨率,提升实时性 process_width = 640 process_height = 480 # 初始化背景分割器 # history: 背景模型记忆帧数,500表示约20秒(按25fps估算) # varThreshold: 方差阈值,越小越敏感 # detectShadows: 开启阴影检测,阴影区域在前景掩码中会标记为灰色(127) fgbg = cv2.createBackgroundSubtractorMOG2( history=500, varThreshold=20, detectShadows=True ) # 形态学操作的内核,用于去除噪声和填充空洞 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) # 最小轮廓面积,小于该面积的轮廓视为噪声,单位是像素 min_area = 500 print("开始检测,按 q 退出 ...") while True: ret, frame = cap.read() if not ret: print("读取帧失败,视频可能结束或拉流中断") break # 统一缩放处理尺寸,减少计算量 frame = cv2.resize(frame, (process_width, process_height)) # 应用背景模型,得到前景掩码 # 掩码像素值:0=背景,255=前景,127=阴影 fgmask = fgbg.apply(frame) # 阴影区域像素值为127,如果不想把阴影当目标,将其置为背景 _, fgmask = cv2.threshold(fgmask, 200, 255, cv2.THRESH_BINARY) # 形态学处理:先腐蚀去除孤立噪点,再膨胀填充目标内部空洞 fgmask = cv2.morphologyEx(fgmask, cv2.MORPH_OPEN, kernel) fgmask = cv2.morphologyEx(fgmask, cv2.MORPH_CLOSE, kernel) # 查找轮廓 contours, _ = cv2.findContours( fgmask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE ) # 在原图上绘制检测框并统计目标数量 target_count = 0 for contour in contours: area = cv2.contourArea(contour) if area < min_area: continue target_count += 1 x, y, w, h = cv2.boundingRect(contour) # 过滤掉太小的框 if w < 20 or h < 20: continue # 绘制外接矩形和质心 cv2.rectangle(frame, (x, y), (x + w, y + h), (0, 255, 0), 2) moments = cv2.moments(contour) if moments["m00"] != 0: cx = int(moments["m10"] / moments["m00"]) cy = int(moments["m01"] / moments["m00"]) cv2.circle(frame, (cx, cy), 4, (0, 0, 255), -1) # 画面左上角显示目标数量 cv2.putText( frame, f"Targets: {target_count}", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2 ) cv2.imshow("Motion Detection", frame) # 按 q 键退出 if cv2.waitKey(1) & 0xFF == ord("q"): break cap.release() cv2.destroyAllWindows() if __name__ == "__main__": main()这个脚本的核心流水线是:读取帧 → 缩放 → 背景模型提取前景 → 阈值化去掉阴影 → 形态学去噪 → 轮廓查找 → 面积过滤 → 画框。每一步都有明确目的,多一步不多,少一步不少。
你可以把source从0改成一段视频文件的路径,测试时更方便,比如source = "test_video.mp4",不用每次都对着摄像头调试。也可以改成RTSP地址,逻辑完全不变。缩放这一步很多人不理解为什么把1280x720的原帧压到640x480,因为后续所有计算都是逐像素的,分辨率降低一半,计算量直接降到四分之一,而且轮廓提取和面积过滤对小目标的敏感性并不会因此损失太多。实时性要求高的场景,甚至可以压到320x240。
detectShadows=True这里有个细节:MOG2会把阴影区域标记为127(灰色),而不是和前景一样是255。如果你直接对前景掩码做轮廓查找,阴影区域也会被当成轮廓的一部分,导致检测框被“撑大”,甚至两个相邻目标被阴影连成一个框。所以我加了一步阈值化:把小于200的像素全部置0,这样阴影(127)就被清掉了,只保留真正的前景(255)。
代码里我还用到了cv2.moments计算轮廓质心。质心在后面扩展横线计数、轨迹追踪时非常有用,先在这里把基础打好。画框用的是外接矩形cv2.boundingRect,如果目标有旋转角度(比如车辆斜着停、人斜着走),也可以换成cv2.minAreaRect画旋转矩形,这个后面再细说。
5. 参数调优实战:从“能出框”到“框得准”
脚本能跑起来之后,接下来面对的问题几乎一定是:检测框乱跳、该检测的没检测到、不该检测的疯狂触发。这时候就要进入调参环节。我先给出一张参数参考表,再逐个讲解每个参数在实际项目中怎么定。
| 参数 | 位置 | 作用 | 参考值 | 调整方向 |
|---|---|---|---|---|
| history | createBackgroundSubtractorMOG2 | 背景模型记忆帧数 | 500 | 场景变化快则调小,需要稳定背景则调大 |
| varThreshold | createBackgroundSubtractorMOG2 | 前景判断敏感度 | 16~25 | 误检多则调大,漏检多则调小 |
| min_area | 轮廓面积过滤 | 忽略小面积噪声 | 画面面积的0.01%~0.05% | 目标小则调小,噪声多则调大 |
| 框宽高阈值 | boundingRect之后 | 过滤过窄/过高的不靠谱框 | 20x20 | 按目标最小尺寸设定 |
| 形态学kernel | getStructuringElement | 控制去噪强度 | 5x5 | 噪声多则调大,目标小则保持小 |
先说varThreshold。这个值是运动检测中最常调整的参数。数值越小,像素颜色只要稍微偏离背景模型就会被判定为前景,结果是检测灵敏度高,但背景噪声、摄像头传感器抖动、极微弱的光线变化都会触发检测框。数值越大,只有颜色明显偏离背景模型的像素才会被判为前景,漏检率上升但误检率下降。我的经验是:室内固定光照下16到20比较合适;室外白天、有风、有树叶晃动的场景,直接干到25甚至30也不奇怪。你怎么判断调得好不好?看前景掩码里的白色噪点密度。如果掩码图里到处是白色小点,说明varThreshold太小,适当加大;如果掩码图里明明有目标区域却几乎全黑,说明阈值太大。
history对检测效果的影响比较隐蔽,但很关键。它决定背景模型会用多少帧来“记住”背景。两年前我在一个仓库门口做过测试,history设默认500时,傍晚自然光逐渐变暗,背景模型能平稳适应,基本不产生误报;把history改成100后,光照稍微变化就会把整片背景当目标触发。原因不难理解:history小意味着模型“忘得快”,光线的渐进变化还没来得及被建模成熟,就被判定为偏离背景。反过来,如果场景中需要尽快适应“新背景”(比如室外的树被风吹动幅度突然变大),history太大反而会让模型反应迟钝,导致大片误检。户外场景我一般设300,室内固定场景设500到800都行。
min_area这个参数容易被新手忽略,但它是过滤噪声最关键的一道闸门。它的本质是“多小的变化算目标”。你需要对画面尺寸有一个基本概念:在640x480的画面里,一个站在十米外的人,轮廓面积大概在几百到一两千像素之间;一只飞过的鸟可能只有几十像素;摄像头传感器热噪声产生的孤立点则可能只有几个像素。我习惯把min_area设为画面总面积的0.01%到0.05%,也就是640x480画面下大约300到1500像素,具体看目标在画面中的实际占比。
形态学kernel大小同样不能乱拍脑袋。MORPH_OPEN(先腐蚀后膨胀)用来去除前景掩码里的孤立白色噪点,kernel太小去不干净噪点,太大则会把小目标的边缘全部腐蚀掉。MORPH_CLOSE(先膨胀后腐蚀)用来填充目标内部的黑色空洞,kernel太小填不住大空洞,太大则会把两个距离很近的目标粘连成一个。默认5x5是个平衡点,如果目标很小(比如只有50x50像素),建议改到3x3;如果目标很大且噪声明显,可以试7x7。
调参还有一个顺序问题值得说。很多新手拿到脚本就开调,结果越调越乱。正确思路应该是:先把varThreshold调到前景掩码不包含明显背景噪声的程度,再把min_area调到能过滤掉小噪声点的程度,最后微调形态学kernel来让掩码区域更规整。每一步调完都跑一段视频看看效果,一次只动一个参数,不要同时旋多个旋钮,否则你根本不知道效果变化是哪个参数引起的。
6. 避坑记录:光照突变、拉流中断和CPU告警
这一节专门记录我实际项目中碰到过的几类典型问题,它们都不是算法本身的问题,但处理不好能让你彻底怀疑人生。
第一类是光照突变引发的全场误报。最常见的是室内日光灯刚刚打开、室外太阳从云层后出来、或者夜间车辆大灯扫过画面。这个问题的根源不是背景模型不好,而是整个画面的像素值在极短时间内发生整体偏移,任何像素级的背景模型都会瞬间把大面积区域判定为前景。我的应对策略分三层:第一层,初始化时把varThreshold适当调大,给背景模型留出灰度波动的容忍空间;第二层,在前景掩码上增加一个全局抑制逻辑——如果某帧的前景像素占比超过了画面总面积的50%,大概率是光照突变而非真实目标,直接把这帧的结果丢弃不画框;第三层,history不要设得太小,让背景模型有足够的历史记忆来平滑短暂突变。这套组合拳实测能过滤掉绝大多数光照突变场景。
第二类是RTSP拉流中断问题。前面的代码里只写了if not ret: break,这在摄像头和本地视频文件场景足够,但RTSP网络流一旦断流,这个逻辑会直接结束程序。实际部署时,恰恰是需要程序7x24小时稳定运行的。我给RTSP流写了一个带自动重连的读取封装,核心逻辑是:连续读取失败超过N帧,就释放掉旧的VideoCapture对象,延迟几秒后重新创建,直到重新连上为止。伪代码思路如下:
def safe_read(cap, max_fail_frames=10): fail_count = 0 while True: ret, frame = cap.read() if ret: return True, frame fail_count += 1 if fail_count > max_fail_frames: print("拉流连续失败,尝试重连...") cap.release() time.sleep(3) cap = cv2.VideoCapture(rtsp_url) fail_count = 0 return False, None顺手给RTSP加一个cap.set(cv2.CAP_PROP_BUFFERSIZE, 1),这个参数能避免因网络延迟导致OpenCV内部缓冲区堆积旧帧,处理时画面突然“跳帧”回放。注意,这个设置不是所有平台都生效,但对部分Linux版本有实质改善,加上没坏处。
第三类是CPU占用过高导致系统卡死或发热。背景减除本身已经是轻量算法,但如果视频源是4K分辨率、或者写代码时忘掉了缩放直接逐帧处理全分辨率,CPU占用率随随便便就超过100%。我的经验是:只要是实时检测,统一把处理分辨率压到640x480或更低;如果目标尺寸本身就很小,分辨率压得太低会导致目标在画面上只有几个像素,此时优先压到960x540。另外,摄像头帧率如果是30fps,而你的处理逻辑一帧要跑80毫秒(即约12.5fps),那么每一帧都会被处理,不会有丢帧问题;如果处理一帧要100毫秒以上,建议主动设置cap.grab()跳过部分帧,保证响应延迟可控。树莓派这类低算力设备上,我一般会把处理帧率控制在10到15fps,配合跳过帧逻辑,CPU占用能保持在40%上下。
第四类是显示器环境下cv2.imshow和cv2.waitKey的配套问题。很多新手会把imshow写在循环里但忘了waitKey,然后发现窗口直接无响应。记住一条铁律:cv2.imshow必须配合cv2.waitKey才能正常刷新和响应键盘事件。另外在无显示器的服务器上跑检测,imshow会直接报错,这时候要把显示代码去掉,改成把检测结果用cv2.imwrite存成图片,或者用cv2.dnn的输出配合JSON序列化,把结构化结果推送给其他服务。
第五类是OpenCV版本差异导致的API变化。4.5.x之后,cv2.findContours的返回值个数从3个变成了2个,contours, hierarchy = cv2.findContours(...),之前是三元组,很多老教程没更新,代码直接ValueError: not enough values to unpack。建议所有找轮廓的代码都按2个返回值来写,新版本兼容,旧版本用补丁处理一下。
7. 进阶玩法:从画出方框到业务落地
运动检测画出检测框只是第一步,实际业务里几乎不会只要一个方框。下面这几个方向是我在客户项目里用得最多的扩展,你可以按需挑选集成。
ROI区域检测是排在第一位的刚需。很多场景下你只关心画面中某个区域的运动情况:比如门口外的车道车辆经过不关心,但有人走进门廊就要告警。实现方式非常简单:用cv2.selectROI交互式框选感兴趣区域,生成一个掩码,然后把前景掩码和ROI掩码做cv2.bitwise_and,之后再进入轮廓查找流程。这样能大幅减少无效触发,也能降低计算量。
横线越界计数是另一个高频需求,场景是统计进出人数或车辆数。实现思路是在检测框的基础上,取目标的质心坐标,然后定义一个虚拟横线(一条水平线或竖直线),每帧记录每个目标质心在上一次的位置,当质心从线的A侧穿越到B侧时,计数加一。这种逻辑可以用一个简单的字典追踪每个轮廓前后帧的匹配关系来实现,轮廓少的时候很稳定。更复杂的场景可以引入cv2.TrackerKCF或cv2.TrackerCSRT做目标跟踪,用跟踪结果来维持目标ID,避免同一目标被重复计数。
夜间红外场景下,画面是黑白的,运动检测的目标是发光体(人体、车辆大灯)以及它们周围的晕影。红外画面下背景模型同样有效,但要注意:环境温度变化(比如傍晚降温)可能导致大范围红外辐射变化,引发和光照突变类似的误报。这时候需要把参数调得更保守一点,同时结合目标面积和宽高比过滤。
如果检测到运动后需要产生告警行为,常见的选择有:截取当前帧保存到本地、通过企业微信/钉钉机器人推送消息、调用HTTP接口通知后端业务系统、驱动蜂鸣器或IO模块。以推送企业微信机器人为例,检测到目标后用cv2.imencode把当前帧转成base64字符串,附带检测框坐标信息,一起POST到webhook地址就行。整个链路跑通后,一个“摄像头发现运动目标就告警”的小系统就完成了。
最后提一句性能优化方向。单线程处理在设备性能紧张时很容易成为瓶颈,标准解法是把视频采集和图像处理拆成两个线程:采集线程只负责cap.read()并把图像放入队列,处理线程从队列取帧做检测。Python的GIL覆盖不到OpenCV底层C++实现,所以多线程在OpenCV场景中收益是明显的。队列大小设1到2即可,不要无脑缓存,否则实时性就丢了。需要更高性能时,可以对整帧检测加上ROI预筛选,或者把模型推理放到GPU/VPU上做,这些方向可以根据项目预算和硬件条件逐步探索。