简介:Python通过ONVIF协议获取RTSP视频流,是网络监控开发中常见的对接需求,不少开发者会在设备发现、媒体服务调用和视频流解析环节遇到阻碍。面向需要接入IP摄像头的运维、测试及安防开发人员,该压缩包演示了使用pyonvif获取媒体Profile、解析RTSP地址,并结合OpenCV完成视频帧读取与截图保存的完整思路。包内共3个文件,包含一个Python调用脚本、一份OpenCV调用方法说明(txt),以及一个第三方辅助软件(msi),压缩包大小约20.35MB,整体结构轻量实用。目前已有2152人浏览学习,具有真实场景参考价值。借助包内脚本,读者可快速建立ONVIF与RTSP的联动认知,避开常见的设备认证失败、取流地址缺失等坑点,并可直接基于脚本做抓图、录流或二次封装。
1. 用 ONVIF 协议拿下 RTSP 流:这份 Python 源码包值不值得你下载
做视频接入的开发者多半都有过这种经历:客户扔来一台摄像头,只给了 IP 和密码,既不知道 RTSP 端口是多少,也不清楚视频编码是 H.264 还是 H.265,更别提抓图截图用什么接口。你登录网页后台翻半天,找到的 RTSP 地址还不一定能在自己的播放器里兼容。这个源码包解决的就是这个痛点:用 Python 通过 ONVIF 协议自动发现设备、探测媒体能力、拿到标准 RTSP 地址,再顺手实现视频码流拉取和抓图截图。我拆读了一遍,发现它并不是简单封装 onvif 库调接口,而是把「设备发现 → 能力探测 → 取流 → 截图」这条链路的每一步都显式写清楚,严重适合正在做视频平台、NVR 对接或二次开发的开发者,新手照着跑一遍也能摸清 ONVIF 的基本交互逻辑。
2. ONVIF 协议基础与技术栈:为什么先要理清设备发现和媒体服务
2.1 ONVIF 到底在做什么:从 WS-Discovery 到 Media Service
ONVIF 标准协议本质上是基于 Web Service 的一套接口规范,设备端通过 SOAP 报文暴露能力。整个协议栈分几层:最底层是 WS-Discovery,负责在局域网里找到设备;往上是 Device Service,处理鉴权和基本信息;再往上是 Media Service,负责视频编码配置和 RTSP 拉流地址的生成。这个源码包把这几层在代码里做了清晰的分离,你拿着摄像头 IP、用户名、密码,程序先探测设备服务地址,再验证凭据,最后才走到取流环节。
实际交互流程是这样的:程序先发一条 WS-Discovery 的探测报文(Probe),摄像头收到后返回自己的 XAddr(服务入口地址);拿到这个地址后,程序用用户名密码创建 ONVIF 服务客户端,调用 GetSystemDateAndTime 这类方法验证鉴权是否通过;鉴权通过后再调 GetProfiles 拿到视频编码配置文件的 Token,最后拿着 Token 调 GetStreamUri 换取 RTSP 地址。这套链路走完,你才真正拿到一个可以丢给 VLC 或 OpenCV 直接播放的统一资源定位符。
这个源码包的亮点在于,它没有把这些流程封装成一个黑匣子,而是用类和方法把每一步独立出来,方便你按需调用或做二次裁剪。比如你只需要拉流不要截图,那直接跳过抓图模块即可,不用拖着整包代码走。
2.2 技术选型剖析:onvif-zeep 与 OpenCV 的分工
这个包最核心的技术依赖是 onvif-zeep 这个 Python 库。它底层基于 Zeep——一个纯 Python 的 SOAP 客户端库,Zeep 负责把 ONVIF 的 WSDL 文件编译成 Python 对象,从而支持你像调用本地方法一样调用远程摄像头的服务。选择 onvif-zeep 而不是自己拼 SOAP 报文,是有原因的:ONVIF 的命名空间极其复杂,服务地址、鉴权头、媒体参数全部要按 XML Schema 组织,手写报文极易在鉴权头格式上翻车,而 onvif-zeep 已经把 WSDL 匹配和 SoapHeader 封装好,你只需要提供host、port、user、passwd四个核心参数。
取流之后,视频码流的消费和处理走的是 OpenCV。如果摄像头输出的 RTSP 地址能直接用 OpenCV 的VideoCapture打开,那说明地址有效;抓图截图则分成两种方式:一是直接从 RTSP 流里取一帧帧数据存成图片,适合实时画面;二是通过 ONVIF 的 GetSnapshotUri 接口拿一个静态截图地址,再用 HTTP 请求拉下来保存,适合低带宽场景下的快速抓图。源码包里两种方案都有,后文会逐个拆解。
2.3 包结构总览:拿到压缩包后先看这两层
解压后整个项目结构很直白,没有乱七八糟的嵌套。common/目录放公共工具,比如日志初始化、常量定义、配置文件读取;onvif_utils/目录放设备操作的封装类;services/放业务服务层,负责把设备操作组装成完整流程;run_demo.py是入口演示脚本。模块之间单向依赖,从common到services,不存在循环引用。
目录结构上,核心的device.py、media.py、snapshot.py被放在onvif_utils/下,这意味着它们可以被独立复用,也能被业务层直接调用。这种设计对二次开发很友好,你只对接其中某一个功能时,不需要把整个服务层拖走。
3. 核心模块拆解:设备发现、鉴权、取流到抓图截图的完整链路
3.1 设备抽象类 DeviceManager:拿到 IP 后的第一件正事
在 ONVIF 交互里,设备服务地址 XAddr 的获取是整个流程的起点。源码包里的DeviceManager类做了两件事:一是用onvif_zeep初始化设备客户端,二是把设备的系统信息、能力信息、时间信息一次性拉取回来。这里有一个关键点:必须先调update_xaddr把设备地址绑定到服务客户端上,否则后续所有媒体操作都会因为地址不匹配而失败。
from onvif import ONVIFCamera class DeviceManager: def __init__(self, host, port, user, password): self.host = host self.port = port self.user = user self.password = password # 创建 ONVIF 客户端,这里传的 host 是摄像头 IP,port 默认是 80 self.camera = ONVIFCamera(host, port, user, password) # 拉取设备信息,验证凭据是否有效 self.device_info = self._get_device_info() def _get_device_info(self): """获取设备制造商、型号、固件版本等信息,用于验证连接是否成功""" device = self.camera.create_devicemgmt_service() info = device.GetDeviceInformation() return { 'manufacturer': info.Manufacturer, 'model': info.Model, 'firmware_version': info.FirmwareVersion, 'serial_number': info.SerialNumber, }ONVIFCamera是onvif-zeep库提供的入口类,create_devicemgmt_service()会从 WSDL 定义中生成设备管理服务客户端。GetDeviceInformation()方法涉及的 SOAP 报文结构虽然复杂,但库内部处理了命名空间问题。这段代码的__init__里即使不传端口,onvif-zeep 也会默认 80,但如果摄像头工作在非标准端口(某些厂家的摄像头 80 被占用时会跳到 8000),需要手动指定。所以我在实际使用时会先探测一下端口存活再初始化。
设备信息拿到后,下一步是检查设备支持的介质配置。这里容易出现的一个坑是:不同厂家的设备对GetProfiles返回的 Profile 数量不一样,有些设备只有一个 Profile,有些有两个(主码流和子码流各有独立 Token)。源码包的media.py里专门做了一层筛选逻辑,按Width和FrameRate排序找主码流。
3.2 媒体服务封装:GetStreamUri 才是拿 RTSP 地址的真正入口
拿到 Profile Token 后,RTSP 地址的获取是通过媒体服务的GetStreamUri接口完成的。这个接口需要你传一个StreamSetup结构,里面指定传输协议是 RTSP 还是 RTMP,以及传输模式是单播还是组播。源码包的实现里默认走 RTSP 单播,这也是目前兼容性最好的取流方式。
from onvif import ONVIFCamera class MediaService: def __init__(self, camera: ONVIFCamera): self.media = camera.create_media_service() def get_stream_uri(self, profile_token, protocol='RTSP'): """ 获取 RTSP 取流地址 :param profile_token: 视频编码配置的 Token,由 GetProfiles 返回 :param protocol: 传输协议,默认 RTSP,也支持 RTMP """ # StreamSetup 是 ONVIF 协议中取流的核心请求体 stream_setup = { 'Stream': 'RTP-Unicast', # 单播模式 'Transport': {'Protocol': protocol}, # RTSP over UDP/TCP } # 调用的 GetStreamUri 接口返回一个 Uri 字符串 uri = self.media.GetStreamUri({ 'StreamSetup': stream_setup, 'ProfileToken': profile_token, }) return uri.Uri关于这段代码有一个值得展开的点:GetStreamUri返回的地址是摄像头上当前活跃的编码格式对应的地址。如果摄像头把 H.264 和 H.265 都做成 Profile,你需要根据自己播放器的解码能力选择对应 Profile 的 Token,否则拉回来的流可能无法解码。还有一种常见做法是先用GetVideoEncoderConfigurations拿到编码配置列表,找出 Encoding 为 'H264' 的那条配置,再用GetCompatibleVideoEncoderConfigurations关联到对应 Profile。这个源码包对这种筛选逻辑做了支持,但没有显式展开,我会建议你在二次开发时把编码格式过滤写进去。
另外要注意的是,StreamSetup里的Transport.Protocol如果设置成 'RTSP',很多实现在底层其实是 RTSP over UDP,这种设置下如果摄像头和客户端之间有 NAT,会出现 UDP 穿透失败的情况。源码包没有显式暴露 TCP/UDP 切换开关,你如果遇到「地址拿到了但播放器不出画面」,大概率是这个传输层协议的问题。
3.3 拉流与抓图截图:两种方案应对不同带宽场景
取到 RTSP 地址后,源码包给出了两套抓图方案。第一套是用 OpenCV 的VideoCapture拉实时流,从帧缓存里读一帧存图;第二套是走 ONVIF 的GetSnapshotUri接口拿一个静态截图地址,再用 requests 下载。两套方案在snapshot.py模块里有对应实现。
import cv2 import requests import datetime class SnapshotService: def __init__(self, media_service): self.media_service = media_service def capture_by_stream(self, rtsp_url, output_path): """方案一:从 RTSP 实时流中抽帧截图""" cap = cv2.VideoCapture(rtsp_url) if not cap.isOpened(): raise RuntimeError(f"无法打开 RTSP 流: {rtsp_url}") ret, frame = cap.read() if ret: # 添加时间戳水印,方便事后核对抓图时刻 timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%H-%M-%S") cv2.putText(frame, timestamp, (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imwrite(output_path, frame) cap.release() return output_path def capture_by_snapshot_uri(self, profile_token, output_path): """方案二:走 ONVIF 静态截图接口,带宽占用极小""" uri = self.media_service.get_snapshot_uri(profile_token) response = requests.get(uri.Uri, timeout=10) if response.status_code == 200: with open(output_path, 'wb') as f: f.write(response.content) return output_path两种方案有本质区别。capture_by_stream适合需要从实时码流里提取信息帧的场景,比如 AI 推理前取当前画面;但它有一个软肋——如果摄像机是变焦的且当前画面是黑屏(比如夜间无补光),cap.read()返回的ret可能是False,你需要加一个重试机制。capture_by_snapshot_uri更像是一个 HTTP 请求,不涉及播放器状态,稳定性更高,但它的实时性略差,因为有些摄像头静态截图接口是有缓存延迟的,看到的内容可能是几十秒前的画面。
我在实际项目里遇到过一个玄学般的坑:同一个摄像头的GetSnapshotUri地址在局域网内访问正常,放到公网访问就超时,因为摄像头内部的 HTTP 服务默认只监听内网口。这种问题排查起来非常头大,建议你在做远程抓图时先确认摄像头是否开了外网访问权限。
3.4 让鉴权不出幺蛾子:RTSP 地址里带凭据与 soap 头处理
很多开发者拿着GetStreamUri返回的地址直接用,结果发现回调 VLC 里播放时弹窗要用户名密码,搞不懂为什么明明 ONVIF 鉴权已经通过了。这是 ONVIF 和 RTSP 两个体系的割裂造成的:ONVIF 的 SOAP 鉴权用 WS-Security 头,而 RTSP 流的鉴权用的是 Basic/Digest 认证,两者互不相关。
def build_rtsp_url_with_auth(uri_str, username, password): """把用户名密码塞进 RTSP URL,避免播放器二次弹窗""" from urllib.parse import urlparse, urlunparse parsed = urlparse(uri_str) # ONVIF 返回的地址格式一般是 rtsp://ip:port/stream1 # 这里把 user:pass@ 拼到 netloc 部分 netloc = f"{username}:{password}@{parsed.netloc}" return urlunparse(parsed._replace(netloc=netloc))这种方式虽然能解决播放器弹窗问题,但有一个副作用:凭据明文出现在 URL 里,如果被日志系统记录下来,会泄露摄像头账号密码。我一般会在二次开发时把它改成先拉流再单独设置认证头的方式,但这需要播放器层支持,实现复杂度会上去。源码包提供的是一个能直接跑的基线版本,你把上面这个函数挂到取流链路里,就能避免在集成的下游系统里到处填密码。
4. 运行环境准备与部署:从零到跑通的逐步操作
4.1 安装依赖与验证摄像头可达性
第一步是装依赖。源码包根目录下的requirements.txt里列了四件套:onvif-zeep、opencv-python、requests、Pillow(Pillow 通常被用来做图像格式转换,如果你只跑抓图,Pillow 可以省略)。安装命令很简单,但有几个细节值得注意:opencv-python的安装包比较大,如果你的机器上已经有opencv-contrib-python,会有包冲突风险,最好在虚拟环境里操作。
# 创建独立的虚拟环境,避免污染全局 Python 解释器 python -m venv venv source venv/bin/activate # Windows 下是 venv\Scripts\activate pip install -r requirements.txt装完依赖后,先别急着跑脚本,先手动验证摄像头 IP 通不通、ONVIF 端口是否开放。摄像头的 ONVIF 服务默认走 80 端口,有些设备支持自定义端口,最稳妥的探测方式是直接用telnet 摄像头IP 80看端口是否开放。如果网络层不通,后边的所有代码都会卡在超时上,而且 ONVIF 超时时间往往很长(默认 5 秒左右),排查起来很费时间。
4.2 配置好摄像头参数再跑 demo
源码包的入口run_demo.py需要你填四个参数:摄像头 IP、端口、用户名、密码。如果你手头有现成的摄像头,直接改脚本里的常量即可;如果没有,可以到网上找模拟 ONVIF 设备的工具(这类工具不少,能虚拟出标准协议设备,但数据不是真实视频流)。我先提醒一点:模拟设备经常在某些接口上报错,和真实摄像头行为不一致,你如果第一次接触 ONVIF,建议还是找一台真实设备练手。
# run_demo.py 的核心调用链路 from onvif_utils.device import DeviceManager from onvif_utils.media import MediaService from onvif_utils.snapshot import SnapshotService host = "192.168.1.108" # 替换成你的摄像头 IP port = 80 # 替换成摄像头 ONVIF 服务端口 user = "admin" # 摄像头登录用户名 password = "your_password" # 摄像头登录密码 dm = DeviceManager(host, port, user, password) print("设备信息:", dm.device_info) # 拿到设备信息后,创建媒体服务并获取第一个 Profile ms = MediaService(dm.camera) profiles = ms.get_profiles() profile_token = profiles[0].token # 用 Profile Token 换取 RTSP 地址 rtsp_url = ms.get_stream_uri(profile_token) print("RTSP 地址:", rtsp_url) # 拉流截图 snapshot = SnapshotService(ms) snapshot.capture_by_stream(rtsp_url, "snapshot.jpg")这里get_profiles()返回的列表里,token是后续所有操作的关键标识。有些设备的 profile 名称是MainStream和SubStream,你可以按名称过滤出主码流,避免选了子码流导致分辨率过低。代码里直接取了profiles[0],这在实际项目中可能选到子码流,建议改成按名称或分辨率排序。
4.3 跑起来之后的验证手段:用 VLC 和 FFmpeg 双重确认
启动脚本后,如果控制台输出了 RTSP 地址,第一件事是拿起 VLC 播放器输入该地址测试能否出画面。VLC 验证通过说明 RTSP 链路是通的,剩下的事就是看截图文件是否生成且非黑屏。如果你想更自动化地验证,可以用 FFmpeg 直接拉流并输出一帧图像:
ffmpeg -rtsp_transport tcp -i "rtsp://admin:password@192.168.1.108:80/stream1" -frames:v 1 -y check_frame.jpg-rtsp_transport tcp这个参数值得多说一句。很多摄像头默认用 UDP 传输 RTSP,UDP 在弱网下丢包严重,会导致解码花屏;强制 TCP 后虽然有小幅延迟,但稳定性能明显提升。你如果发现源码包里的VideoCapture读帧经常报错,先试试在 RTSP 地址后追加?tcp(部分摄像头支持这种语法),或者改用 FFmpeg 方案验证是不是网络层问题。
5. 常见问题排查与避坑指南:5 个 ONVIF 开发里的血泪经验
5.1 现象一:GetStreamUri 返回了地址,VLC 却打不开
这类问题最常见的根源是StreamSetup里传输协议不匹配。默认情况下 onvif-zeep 会使用 RTSP over UDP,如果你的摄像头和播放器在跨网段或 NAT 环境下,UDP 的 RTP 包会被路由器丢弃,VLC 表现就是「连接超时」或「无法打开 MRL」。解决方法是强制使用 TCP 传输:把Transport里的Protocol改为RTSP同时加一个Connection字段为TCP。具体操作时,要看你的摄像头固件是否支持 TCP 封装的 RTP,多数主流厂商的新款都支持。另一个可能的原因是摄像头开启了加密流,RTSP 地址需要带加密令牌,这种情况在 GetStreamUri 返回的 Uri 里通常会附带?token=xxx,直接使用即可,不要手动删掉。
5.2 现象二:设备信息能拿到,但 GetProfiles 返回空列表
摄像头能鉴权通过,说明 SOAP 报文和用户名密码都没问题,但GetProfiles返回空,通常是设备固件的 ONVIF 实现不完整。部分老款摄像头只实现了 Device Management 服务,没有实现 Media 服务,所以拿不到编码配置。这属于设备兼容性问题,代码层面没有太多办法绕过,只能换一台设备。我一般在采购摄像头前会先要求厂家提供 ONVIF 协议一致性声明,避免买回来发现不支持取流。
5.3 现象三:OpenCV 的 VideoCapture 打开 RTSP 地址永久阻塞
cv2.VideoCapture(rtsp_url)在部分 Windows 环境下会因为底层的 FFmpeg 初始化问题导致open()调用不返回,表现是程序卡死。这个问题被很多人遇到过,原因有两个层面:一是 OpenCV 版本过旧,RTSP 解析模块存在兼容问题;二是摄像头并发连接数已满,新连接排队等待。建议先把 OpenCV 升级到 4.5 以上,同时在打开前用cap.set(cv2.CAP_PROP_OPEN_TIMEOUT_MSEC, 3000)设置超时,避免永久阻塞。
5.4 现象四:抓图截图的图片是全黑的
黑帧问题十有八九是摄像头在抓图瞬间还没有出图。某些摄像头在不满足「有客户端正在拉流」的条件下,VideoCapture.read()会返回一张黑帧。解决方式是在截图前先「预热」拉流:用 OpenCV 打开流后循环读取 30 帧,确认ret为True且帧不是全黑的,再执行保存。还可以从 ONVIF 的GetSnapshotUri路径走,因为这个接口在服务端会等待画面出图才返回数据,黑帧概率低很多。
5.5 现象五:onvif-zeep 初始化时报 Transport Error
ONVIFCamera初始化时报TransportError: 401 Unauthorized,多半是摄像头登录密码错误或账号被锁定。有些设备连续登录失败会自动锁定 IP 一段时间,表现就是「刚才还能通,现在全部 401」。排查时需要确认的事有:密码里不能带@、#等特殊字符(部分设备的鉴权头解析会出错);port参数是否写对了端口;摄像头是否开启了 ONVIF 服务白名单(有些设备只允许特定 IP 访问 ONVIF 服务)。如果怀疑密码含特殊字符导致 SOAP 报文错误,先改成纯字母数字测试。
6. 二次开发的三种改造思路与验证方法
在你把基础链路跑通之后,我建议下一个动作不是往代码里塞新需求,而是先把这个源码包里的主流程封装成一个「设备接入服务」。具体做法是把DeviceManager、MediaService、SnapshotService组装到一个新的CameraClient类里,对外暴露get_rtsp_url()、capture()、get_snapshot()三个方法,让业务层只关心功能,不关心 ONVIF 协议细节。
改造的第一种思路是多设备并发管理。源码包目前是单设备直连模式,但实际项目里你可能是对着几十台摄像头做轮询抓图。这时需要把每个设备的连接参数存进数据库,然后用线程池并发创建客户端。要注意的是 ONVIF 客户端的初始化比较重(要解析 WSDL),不要每抓一次图就重建一个客户端,应该用连接池按设备 IP 做缓存。一个简单的实现是建一个dict全局缓存:camera_pool = {device_id: CameraClient(...)},在回调函数里优先取缓存,缓存未命中再创建。
第二种改造思路是抓图流程的事件化。你可以在capture_by_stream里把「取到帧」变成触发回调,把帧数据直接丢给下游的 AI 推理服务。这个改造的核心是把阻塞的cap.read()循环放进一个独立线程,用队列做帧缓冲,消费端按自己的节奏取帧处理。源码包中的SnapshotService可以扩展成StreamService,增加帧队列和回调注册接口,这样既保留了抓图能力,又支持了实时视频分析。
第三种思路是异步化。ONVIF 的接口调用是同步阻塞式的,在网络抖动时单个GetStreamUri可能耗时 3 秒,如果主流程里连续调用三个接口,用户体验会非常差。我一般会在外部套一层异步包装:把DeviceManager的初始化放到async函数里,用asyncio.wait_for给每个 SOAP 调用设超时兜底。
import asyncio async def get_rtsp_url_async(camera_client, timeout=5): """异步获取 RTSP 地址,避免 ONVIF 调用卡死主线程""" loop = asyncio.get_event_loop() # 把阻塞的同步方法丢到线程池执行,主线程继续响应其他请求 url = await asyncio.wait_for( loop.run_in_executor(None, camera_client.get_rtsp_url), timeout=timeout ) return url这个异步包一层之后,你的 Web 服务就可以同时处理多个取流请求,而不用一个设备卡住全体排队。
最后说说怎么验证改造结果。除了业务功能测试之外,我强烈建议你做一次「断网恢复」测试:拉流过程中直接断开摄像头网络,看代码是抛异常后自动重连,还是永久卡住。一个健壮的设计应该是在cap.read()连续失败 N 次后,自动重新调用GetStreamUri换取全新地址(这个地址有时会和旧地址不同,因为摄像头内部会重新分配端口)。从那以后,我每次做视频接入项目都强制走一遍「网络断开 → 恢复 → 自动重连」的演练,才算敢把代码上生产环境。一套真实设备的 ONVIF 接入链路,踩过的坑大多不在协议本身,而在网络不稳定和设备固件差异,这份源码包能帮你把基础链路的坑填平,接下来就看你自己的业务怎么长了。希望帮到你。
本文还有配套的精品资源,点击获取