想让你的 AI 不只是读文字,还能「看见」现实里正在发生的事?不用买贵的、不用装厂商全家桶、也不用把画面传上别人的云。 一个寻常的家用摄像头,在自己家的网里拉一帧下来,喂给会看图的模型,它就有了眼睛。这篇把整条链路从头讲通—— 找到它、拉一帧、喂给模型——外加两个我亲手踩了大半宿的坑:H.265 灰图,还有 macOS 上那道谁都想不到的 「本地网络墙」。全是实测,不讲黑话。
市面上寻常的家用室内摄像头,正常玩法是:插电、扫码、连上厂商自家的 app,画面走厂商的云,你在手机上看。这条路对我们没用——你的后端进不去那朵云,AI 拿不到画面。
但这类摄像头几乎都藏着另一条路:局域网里的实时流(RTSP)。它自己在家里的网络上开了个 554 端口,把画面按标准协议往外播。你的后端只要在同一个网里,就能直接把这路流接下来、解出一帧图片——不碰厂商 app,不碰云,画面一步都没离开你家。
一句话:别走云,走本地 RTSP。 隐私上这是最干净的做法——图像只在你自己的机器和你自己的模型之间流动。
很多这类摄像头的本地流根本没设密码。这是它的安全短板,却正好成了你自给自足的方便门。(也提醒你:这种摄像头别对着不该拍的地方,本地不设防意味着同网段的人也够得着。)
第一步是知道摄像头在你家网里的地址。三个办法,哪个顺手用哪个:
| 办法 | 怎么做 |
|---|---|
| 路由器后台 | 登进去看「已连接设备」,认出那台摄像头,记下 IP(形如 192.168.x.x) |
| arp 扫一遍 | 用摄像头 MAC 前缀反查,arp -a | grep -i <厂商MAC前缀> |
| 端口扫描 | 全网段找谁开着 554 |
拿到 IP,下一步试出取流路径。RTSP 地址长这样:rtsp://<IP>:554/<路径>。这类摄像头常见的主/子码流路径就那么几个,挨个用 ffprobe 探:
# 主码流(清晰)常见就这几个路径,一个个试
ffprobe -v error -rtsp_transport tcp rtsp://192.168.x.x:554/11
ffprobe -v error -rtsp_transport tcp rtsp://192.168.x.x:554/live/ch00_0
ffprobe -v error -rtsp_transport tcp rtsp://192.168.x.x:554/
有些这类摄像头的服务器对任何路径都回 200,看着像每条都通,其实只有真正带视频的那条才是流。别看状态码,看 ffprobe 打印的流信息里有没有 Video: 这一行(协议层面,是 SDP 描述里的 m=video)。有视频轨的才是你要的地址。
探对了,你会看到类似 Video: hevc (H.265), 2304x1296 —— 记住这个 H.265,坑一就跟它有关。
有了地址,「拍张照」朴素到一行 ffmpeg:
ffmpeg -rtsp_transport tcp -i rtsp://192.168.x.x:554/11 -frames:v 1 -q:v 2 snap.jpg
-rtsp_transport tcp 走 TCP,比默认的 UDP 稳,家里网抖一下也不容易花屏丢包。这一行能出图,链路就算通了。
下面两个坑,一个会让你拍出一张灰图,另一个会让你的后端明明手动能拍、进程里就是拍不到。两个坑我都用 Python 里的 PyAV(pip install av Pillow)解决,它把取流+解码放进你自己的进程里做,不用去 fork 外部的 ffmpeg。原因,坑二会讲透。
第一次把帧接进后端,我兴冲冲拉了一张——一整张灰的,什么都没有,五十来 KB。地址没错、流也在播,怎么会是灰的?
根子在 H.265(HEVC)的编码方式。视频不是一帧帧独立的完整画面,而是隔一段来一张「关键帧」(IDR,一张自足的完整图),中间全是「只记录变化量」的帧。你一连上流,最先解出来的往往是关键帧还没到、只靠变化量拼出来的半成品——于是就是那张灰图。
解法:别抓解出来的第一帧,等到第一张关键帧再抓。
import av
def grab_frame(rtsp_url):
container = av.open(
rtsp_url,
options={"rtsp_transport": "tcp"}, # 跟 ffmpeg 那行同理,走 TCP 稳
timeout=10,
)
try:
img = None
for i, frame in enumerate(container.decode(video=0)):
if frame.key_frame: # 等到第一张「关键帧」——自足的完整画面
img = frame.to_image() # 拿到 PIL.Image
break
if i > 90: # 兜底:万一一直等不到,别死循环
img = frame.to_image()
break
return img
finally:
container.close()
就加了 if frame.key_frame 这一句,灰图变成一张两三百 KB 的真画面。
凡是用 PyAV(或任何解码库)从 H.265 / H.264 流里只取一帧,一律等到 key_frame 再取,别拿到手第一帧就用。这个坑跟画质无关、跟地址无关,纯粹是编码原理,换个摄像头照样会遇上。
这是全文重头戏,也是我卡最久的一坑。现象诡异到让人怀疑人生:
No route to host,空帧,拍不到。同一台机、同一个地址、同一段代码,换个身份跑就不通。排查了一圈(环境变量、路径、权限……全对得上),最后坐实了真相:
macOS 从某个版本起有一道「本地网络访问」权限墙,它是按「发起连接的那个二进制程序的身份」来判权限的,不跟着父进程继承。
拆开说:
ffmpeg 子进程抓帧——发起那条到 192.168.x.x:554 连接的,是 ffmpeg 这个独立二进制。系统看的是它的身份,它没被授过权,于是拦掉 → No route to host。关键的验证:我在后端 Python 里直接开一个 socket 去连摄像头的 554,连得上。说明后端 Python 这个身份是有本地网络权限的,被单独挡在门外的,只是那个被 fork 出去的 ffmpeg 二进制。
于是解法非常干净:别 fork 外部程序,把取流解码放进后端进程自己身上做。 这正是用 PyAV 而不是 ffmpeg 命令的根本原因——发起连接的是有权限的后端进程本身,整道墙自然绕过,用的人一个授权开关都不用点。
# 后端进程里直接这么调,发起连接的是「后端 Python」本人,有本地网络权限
img = grab_frame("rtsp://192.168.x.x:554/11")
后端要抓局域网设备(摄像头、打印机、NAS……)的数据,一律用进程内的库(PyAV、原生 socket、http 客户端),绝不去 fork ffmpeg / curl 这类独立二进制。 否则在 macOS 上会被这道「本地网络墙」按二进制身份单独拦下,而且报得像是「网络不通」,能让你查错方向查一整晚。
附带一个好消息:正因为发起连接的是你的服务进程、不是某个新冒出来的二进制,系统设置里根本不会弹出那个「要不要允许 XX 访问本地网络」的开关——你的用户什么都不用点,它就通了。
抓帧本身是阻塞的(连流、解码要几秒)。如果你的后端是 asyncio 那种事件循环,记得把它丢到线程里去,别卡住主循环:
import asyncio
img = await asyncio.to_thread(grab_frame, rtsp_url) # 别在事件循环里直接干等
到这儿你手里有了一张 PIL.Image。剩下的就是把它编码成模型能吃的格式,塞进一条「看图」消息里。
import base64, io
def to_data_url(img, quality=85, max_w=1280):
# 大图先缩一下,省 token 也省流量
if img.width > max_w:
h = round(img.height * max_w / img.width)
img = img.resize((max_w, h))
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=quality)
b64 = base64.b64encode(buf.getvalue()).decode()
return "data:image/jpeg;base64," + b64
然后当成一条 image 内容发给会看图的模型(下面用 OpenRouter 举例,Anthropic 官方 SDK 的 image block 同理):
import httpx
def look_and_say(img, api_key, question="看看画面里现在是什么情况"):
data_url = to_data_url(img)
r = httpx.post(
"https://openrouter.ai/api/v1/chat/completions",
headers={"Authorization": "Bearer " + api_key},
json={
"model": "anthropic/claude-opus-4-8", # 挑一个会看图的模型
"max_tokens": 1000,
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": question},
{"type": "image_url", "image_url": {"url": data_url}},
],
}],
}, timeout=90,
)
return r.json()["choices"][0]["message"]["content"]
就这样,一台寻常的家用摄像头 + 十来行代码,你的 AI 从「只会读字」变成了「能瞥一眼现实、然后开口说它看到了什么」。它会告诉你桌上堆了什么、椅子上有没有人、窗外是白天还是黄昏——画面从没离开过你家的网。
上面是「你叫它看,它才看」。再往前一步,是让模型自己在对话里决定要不要瞥一眼——比如它聊着聊着好奇「你那边现在什么样」,就自己去看一下再接着说。
做法我在另一篇讲手写标签的思路上稍作延伸,这里只说骨架:给它一个暗号标签,让它想看时就写这个标签、然后立刻收笔;你的后端在这一段流结束后发现了这个暗号,就去抓一帧,把图回灌进同一轮对话,让它「看着画面」把话接着说完。于是一次回复分成两段——先冒出「我看看啊」,抓帧,再带着真实画面往下说。实测它第二段说的全是画面里真有的东西,不瞎编。
(这一段展开够写另一篇了,暗号两段式、状态机怎么归位、人称别在回灌指令里跑偏……坑不少,按下不表。)
给 AI 装眼睛,听着像个大工程,其实拆开就三步:找到那路本地流、抓一张真的关键帧、把它编码喂给会看图的模型。 难的从来不是哪一步的代码,是中间那两个不写出来你绝对想不到的坑——一张灰图和一道按二进制身份判权限的墙。绕过它们,一台寻常的家用摄像头,就能让你那个只会读字的 AI,第一次真正看见你所在的那个房间。