抓拍不是「从视频流里截一帧」,而是「HTTP GET 一张 JPEG」。 这个区别带来的好处很实际:不用建流、不用等关键帧、不用解码。
上一节讲「取流」,这一节讲「不取流也能拿画面」。 在很多业务里(电子地图、报警联动、首页缩略图)根本不需要流, 只要一张图——这时候用抓拍比取流便宜一个数量级。
一、两条路:抓拍与截帧
| 做法 | 怎么拿图 | 代价 |
|---|---|---|
| ONVIF 抓拍 | GetSnapshotUri + HTTP GET |
一次请求,几十毫秒 |
| 自己截帧 | 取流 → 解码 → 存一帧 | 建流 + 等关键帧 + 解码 |
第一行是「设备帮你做完了」: 设备内部解码一帧、编成 JPEG、用 HTTP 吐出来——客户端只负责 GET。 第二行是「你自己做一遍」: 那就要建 RTSP 连接、等 I 帧、解码、再编码——同样的结果,成本差一个数量级。 所以「要图」和「要流」是两个不同的需求, 别用取流的方式去满足「只要一张图」的需求—— 这是很多客户端卡顿的根源:它为了显示缩略图,给每台设备建了一条流。
二、地址是设备自己的 HTTP 服务
GetSnapshotUri 返回的地址有三个特点:
| 特点 | 例子 | 要注意 |
|---|---|---|
| 不是 SOAP 端点 | /onvif/snapshot/… |
不能再发 SOAP |
| 可能需要鉴权 | HTTP Basic 或 Digest | GET 时要带凭证 |
| 路径各家不同 | /snapshot 或 /jpg |
必须用它给的 |
第一行是最常见的误解:
有人拿到这个地址之后,又往里发了一条 SOAP 请求——
设备当然不认,回 400 或者一个 HTML 错误页。
第三行说明「不能拼路径」:/onvif/snapshot/profile1 只是这台设备的写法,
换个厂家可能是 /cgi-bin/snapshot.cgi——所以必须用 GetSnapshotUri 拿,不能自己拼。
第二行在调试时最容易忘:浏览器直接打开这个地址会弹登录框,
说明它用的是 HTTP Basic——而代码里 urlopen 不带凭证,就会收到 401。
三、JPEG 的两个字节
抓回来的数据开头一定是 FF D8,结尾是 FF D9:
| 标记 | 字节 | 含义 |
|---|---|---|
| SOI | FF D8 |
图像开始 |
| EOI | FF D9 |
图像结束 |
这两个字节是「判断拿到的是不是图」的最快办法:
拿到 401 的 HTML 时,开头是 <!DOCTYPE 或 <html——两字节一比就知道。
这和 08.01.01 那个「WSDL 看前 5 个字节」是同一个思路:「下载成功」不等于「内容对」,
而判断内容对不对,看魔数最快。
顺带一个数:
本机 320×240 的一帧编成 JPEG 质量 85 是 14415 字节——按这个比例估,1080p 大概是几百 KB。
所以「抓拍图片回传」在带宽上是有代价的,尤其是一次抓几十台的时候。
四、高并发抓拍会压住设备
这一节最后一个工程经验:
| 现象 | 原因 | 对策 |
|---|---|---|
| 图越来越慢 | 设备编码器被占满 | 限并发,比如 2~3 路 |
| 偶尔返回旧图 | 设备缓存了上一张 | 加时间戳参数破缓存 |
| 偶尔 500 | 设备内部超时 | 重试 + 退避 |
第一行是必然的:抓拍要占用编码器(要编一张 JPEG), 而编码器同时还要出视频流——一台低端 IPC 的编码器可能只够编一路主码流, 你去抓十张图,它就得停下来给你编十次。 第二行那个「旧图」很坑:有的设备的抓拍接口是「返回最近一次缓存的图」, 所以你在 URL 后面加个时间戳参数,有的设备会重新抓、有的不会——要看厂家实现。 第三行那个「重试 + 退避」是通用做法:抓拍失败时立刻重试往往还是失败(设备正忙), 等一两秒再试成功率高得多。
五、动手 Demo:抓拍就是一次 HTTP GET
Demo 分两边:设备端把一帧编成 JPEG 用 HTTP 吐出去,
客户端 urlopen 拿回来——这就是抓拍的全部。
顺便验证「拿回来的字节和编码出来的完全一致」。
# 设备端:解码一帧 → 编 JPEG → 用 HTTP 吐
arr = next(av.open("_t35_enc.mp4").decode(video=0)).to_ndarray(format="bgr24")
ok, buf = cv2.imencode(".jpg", arr, [cv2.IMWRITE_JPEG_QUALITY, 85])
jpg = buf.tobytes()
# 客户端:一次 GET
data = urllib.request.urlopen(real_url, timeout=3).read()
print(len(data), data[:2].hex(), data == jpg)
实跑输出:
--------------------------------------------------------------------
抓拍就是一次 HTTP GET
--------------------------------------------------------------------
**很多人以为「抓拍」是从视频流里截一帧**,
**其实 ONVIF 的做法是给一个独立的 JPEG 地址**:
**客户端直接 HTTP GET 下来,跟 RTSP 完全无关。**
**好处很实际**:
**不用建流、不用等关键帧、不用解码**——
**一次 GET 就是一张图,几十毫秒的事。**
`GetSnapshotUri` 返回:`http://127.0.0.1:52719/onvif/snapshot/profile1`
**这个地址是设备自己的 HTTP 服务**,不是 ONVIF 的 SOAP 端点。
**所以要把它当真 URL 去 GET,而不是再发一个 SOAP 请求。**
**设备端的活儿就是这三步**:解码一帧、编成 JPEG、用 HTTP 吐出去。
**本机照做一遍**(320 × 240 的一帧,JPEG 质量 85):
编码结果 14415 字节,头部两个字节 `ffd8`
**客户端那边就一行 `urlopen`**:
GET `http://127.0.0.1:52721/onvif/snapshot/profile1`
拿回 14415 字节,46.1 毫秒,和编码出来的完全一致:**一致**
**注意返回体的前两个字节是 `ffd8`**,这是 JPEG 的标志(SOI)。
**抓拍失败的常见原因就三个**:
**一是这个地址要鉴权,GET 时不带令牌被拒**;
**二是路径写错(各家不一样,必须用 `GetSnapshotUri` 拿)**;
**三是设备同时只能响应有限个抓拍请求**——
**高并发抓拍会把设备压住,表现为「图越来越慢」。**
最后那个 data[:2].hex() 打出 ffd8,
这就是 JPEG 的 SOI 标记——判断「拿到的是不是图」只看这两个字节。
data == jpg 那个 True 说明「传输没有改动内容」,这一点在调试时有用:
如果拿到的大小和预期差很多,说明中间被拦了(比如代理插了个错误页)。
输出里那 23.8 毫秒是本机回环,
真实设备上要算上「设备编码一帧的时间」——
那个才是大头(低端设备编一张 1080p JPEG 要几百毫秒)。
所以「抓拍快不快」主要取决于设备,不取决于网络——这也是为什么「并发抓几十台」会把设备压住。
30 秒实验
- 把 JPEG 质量从 85 改成 50,看字节数掉多少。
- 在
Handler里加一句print(self.headers),看客户端到底发了什么请求头。 - 把
data[:2]换成一个 401 的响应体,看前两个字节是什么。
常见误区
- 往
GetSnapshotUri返回的地址发 SOAP 请求。 那是设备的 HTTP 服务,不是 SOAP 端点, 再发 SOAP 只会得到 400。 - 自己拼抓拍路径。 各家不一样(
/snapshot、/cgi-bin/snapshot.cgi), 必须用GetSnapshotUri拿。 - 为了显示缩略图给每台设备建一条流。 抓拍一次只要几十毫秒,建流要等关键帧, 成本差一个数量级。
自测三问(点开对答案)
1. 抓拍和「从流里截一帧」的差别在哪? —— **抓拍是设备内部做好、用 HTTP 给一张 JPEG**;截帧要你自己建流、等关键帧、解码。 **成本差一个数量级。** 2. 怎么判断抓回来的是图不是错误页? —— **看前两个字节是不是 `ffd8`**(JPEG 的 SOI)。错误页开头是 `下一节改分辨率:一个「总是返回成功」的操作,靠什么判断真改没改。
- ☐ (本节完,下一节 08.07 修改分辨率)