本地 RTSP · PyAV · 会看图的 Claude · 给 AI 装眼睛

给你的 AI 装一双眼睛
——把一个家用摄像头接进自己的后端

想让你的 AI 不只是读文字,还能「看见」现实里正在发生的事?不用买贵的、不用装厂商全家桶、也不用把画面传上别人的云。 一个寻常的家用摄像头,在自己家的网里拉一帧下来,喂给会看图的模型,它就有了眼睛。这篇把整条链路从头讲通—— 找到它、拉一帧、喂给模型——外加两个我亲手踩了大半宿的坑:H.265 灰图,还有 macOS 上那道谁都想不到的 「本地网络墙」。全是实测,不讲黑话。

目录
0 · 先说清思路:绕开云和厂商 app,走本地流 1 · 找到它:局域网 IP 和 RTSP 地址 2 · 抓一帧:最朴素的一行 3 · 坑一:抓出来一张灰图(H.265 关键帧) 4 · 坑二:手动能拍、后端拍不到(本地网络墙) 5 · 喂给模型:一帧图,变成「它看见了」 6 · 进阶:让 AI 自己决定什么时候睁眼 一句话收尾

0先说清思路:绕开云和厂商 app,走「本地流」

市面上寻常的家用室内摄像头,正常玩法是:插电、扫码、连上厂商自家的 app,画面走厂商的云,你在手机上看。这条路对我们没用——你的后端进不去那朵云,AI 拿不到画面。

但这类摄像头几乎都藏着另一条路:局域网里的实时流(RTSP)。它自己在家里的网络上开了个 554 端口,把画面按标准协议往外播。你的后端只要在同一个网里,就能直接把这路流接下来、解出一帧图片——不碰厂商 app,不碰云,画面一步都没离开你家。

一句话:别走云,走本地 RTSP。 隐私上这是最干净的做法——图像只在你自己的机器和你自己的模型之间流动。

🔓 顺带一提

很多这类摄像头的本地流根本没设密码。这是它的安全短板,却正好成了你自给自足的方便门。(也提醒你:这种摄像头别对着不该拍的地方,本地不设防意味着同网段的人也够得着。)

1找到它:局域网 IP 和 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,坑一就跟它有关。

2抓一帧:最朴素的一行

有了地址,「拍张照」朴素到一行 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 稳,家里网抖一下也不容易花屏丢包。这一行能出图,链路就算通了。

📌 但真接进后端时,请先把这行 ffmpeg 忘掉

下面两个坑,一个会让你拍出一张灰图,另一个会让你的后端明明手动能拍、进程里就是拍不到。两个坑我都用 Python 里的 PyAV(pip install av Pillow)解决,它把取流+解码放进你自己的进程里做,不用去 fork 外部的 ffmpeg。原因,坑二会讲透。

3坑一:抓出来一张灰扑扑的图(H.265 的关键帧)

第一次把帧接进后端,我兴冲冲拉了一张——一整张灰的,什么都没有,五十来 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 再取,别拿到手第一帧就用。这个坑跟画质无关、跟地址无关,纯粹是编码原理,换个摄像头照样会遇上。

4坑二:手动能拍、后端拍不到 —— macOS 的「本地网络墙」

这是全文重头戏,也是我卡最久的一坑。现象诡异到让人怀疑人生:

同一台机、同一个地址、同一段代码,换个身份跑就不通。排查了一圈(环境变量、路径、权限……全对得上),最后坐实了真相:

🧱 真相

macOS 从某个版本起有一道「本地网络访问」权限墙,它是按「发起连接的那个二进制程序的身份」来判权限的,不跟着父进程继承。

拆开说:

关键的验证:我在后端 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)   # 别在事件循环里直接干等

5喂给模型:一帧图,变成「它看见了」

到这儿你手里有了一张 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 从「只会读字」变成了「能瞥一眼现实、然后开口说它看到了什么」。它会告诉你桌上堆了什么、椅子上有没有人、窗外是白天还是黄昏——画面从没离开过你家的网。

6进阶:让 AI 自己决定「什么时候睁眼」

上面是「你叫它看,它才看」。再往前一步,是让模型自己在对话里决定要不要瞥一眼——比如它聊着聊着好奇「你那边现在什么样」,就自己去看一下再接着说。

做法我在另一篇讲手写标签的思路上稍作延伸,这里只说骨架:给它一个暗号标签,让它想看时就写这个标签、然后立刻收笔;你的后端在这一段流结束后发现了这个暗号,就去抓一帧,把图回灌进同一轮对话,让它「看着画面」把话接着说完。于是一次回复分成两段——先冒出「我看看啊」,抓帧,再带着真实画面往下说。实测它第二段说的全是画面里真有的东西,不瞎编。

(这一段展开够写另一篇了,暗号两段式、状态机怎么归位、人称别在回灌指令里跑偏……坑不少,按下不表。)


一句话收尾

给 AI 装眼睛,听着像个大工程,其实拆开就三步:找到那路本地流、抓一张真的关键帧、把它编码喂给会看图的模型。 难的从来不是哪一步的代码,是中间那两个不写出来你绝对想不到的坑——一张灰图和一道按二进制身份判权限的墙。绕过它们,一台寻常的家用摄像头,就能让你那个只会读字的 AI,第一次真正看见你所在的那个房间。