家里有两只虎斑猫,aki 和 kuro。养了猫之后,Furbo 宠物摄影机的广告就一直出现在我眼前:远端丢零食、AI 宠物保母、每天自动剪好的猫咪日记、呕吐侦测。看久了真的心动,有一阵子差点就下单了。后来认真去查,才发现我想要的功能几乎都绑在订阅里,而且它的录影只存 AI 判断有宠物的片段。

刚好客厅本来就有一台 Tapo C210 一直开着,只是它没有 SD 卡,只能看即时画面、没有回放。所以最后我没买 Furbo,改拿它的功能清单当目标,用 Frigate 加 Home Assistant 自己做:录影可以回看、猫有动静会通知、每天自动整理一份「今天猫做了什么」。做了大概两周,结果是:录影、通知、本机 AI 写的猫咪日记和每日精华影片都能用,唯一没做成的是「分辨是哪一只猫」。我训练了一个分类模型,用同框的画面验证,正确率 38%,比乱猜的 50% 还低。

这篇照顺序记录:我查到的 Furbo 缺点、从零开始的架设步骤(compose 和 config 都附上,换成自己的帐密就能用)、选哪个本机视觉模型、分猫辨识是怎么验出来不能用的、日记和精华影片怎么做,最后是整套跑起来吃多少 VRAM 和硬碟。

差点买 Furbo,查完才发现的事#

以下是 2026 年 10 月在 Furbo 台湾官网查到的:

  • 摄影机:猫咪摄影机 360 度版页面写「最低享 1,699 元」,但那是要同时绑宠物保姆订阅的价格(至少绑 3 个月)。不绑约的单机,官方虾皮卖场是 5,399 元。盒子里不附电源供应器,另购 299 元;Wi-Fi 只支援 2.4GHz。
  • 不订阅能用的:即时画面、双向通话、丢零食、逗猫棒、喵喵通知。任何录影都不能回看。
  • 宠物保姆订阅:标准方案年缴首年 1,840 元(月缴原价每月 219 元),录影保留 3 天;高级方案每月 188 元起,保留 7 天。台湾的两个方案只差在保存天数,猫咪日记、呕吐侦测、多宠辨识两边都有。多一台摄影机,每台每月再加 40 元。

真正让我打消念头的是录影方式。Furbo 的回看是 AI 侦测到宠物才存下来的片段,不是连续录影。App Store 台湾区的负评很多都绕着这点,例如「想看的画面常常都没录到」、「整天传喵喵通知给我,但是呕吐的部分却侦测不到」,另外断线也是很常见的抱怨。Furbo 自己的说明文件也写了「宠物紧急状况」警报的门槛:一天内侦测到两次过度呕吐,或 5 分钟内侦测到两次抽搐;可能的误判来源则列了在花纹地毯上低头、光影变化、把玩具放下。看起来就是低头姿势的判断,再加上重复次数的门槛。我客厅本来就有一台 C210,买 Furbo 等于再多一台只吃 2.4GHz 的摄影机,然后每年付一笔订阅费,换 3 天的 AI 片段。

广告推得这么凶也有原因。数位时代 10 月的专访提到,Furbo 的订阅收入在 2023 年之后超过硬体,现在占整体营收一半以上,策略就是压低摄影机的入手价,再用录影保存天数把订阅分级。

不过查评价的过程也看得出大家最喜欢哪个功能:每天一支 5 秒到 1 分钟、配好音乐的缩时「猫咪日记」影片。

对照我原本的 HA 仪表板,差别很清楚:我做的是「现在画面里有没有猫」,Furbo 卖的是「猫刚刚做了什么」。所以目标就定成三件事:录影回看、事件摘要、每日精华。

从零开始架设#

整套的组成是这样:

  • 镜头:Tapo C210,主串流 2304×1296、子串流 1280×720,只支援 2.4GHz,没有 SD 卡,所以 Frigate 是它唯一的录影方案。
  • 主机:一台 24 小时开着的 Linux 伺服器,显卡是 RTX 5060 Ti 16GB,录影放在 16TB 的硬碟上。
  • 0.17.2:录影、物件侦测、事件管理,跑在 Docker 里。
  • Ollama:在主机上跑视觉语言模型,帮每个事件写摘要。
  • broker:Frigate 和 Home Assistant 之间传状态用。
  • Home Assistant:通知、仪表板、日记页。

下面照我实际架的顺序分成七步,每一步都收起来了,要照着做再点开。设定档都拿掉了帐密和 IP,改成你自己的就能用。

第 1 步:镜头打开 RTSP

Tapo 的镜头要先在 App 里的镜头设定 → 进阶设定,建立一组「摄影机帐户」,这组帐密跟你登入 Tapo 的帐号是分开的。建好之后串流网址是:

text
rtsp://摄影机帐户:密码@镜头IP:554/stream1   # 主串流
rtsp://摄影机帐户:密码@镜头IP:554/stream2   # 子串流

接上去之前先用 ffprobe 量一次实际的解析度和位元率,不要照规格表或网路上的说法填。我的 C210 实测主串流是 2304×1296、25fps,位元率约 1.15 Mbps,换算一天大约 11.6GB;子串流 1280×720、约 0.15 Mbps。Frigate 0.17 的解析度自动侦测会出错,官方建议在 config 里把 detect 的宽高写死,用的就是这里量到的值。另外 Tapo 官方 FAQ 有写,云端储存、SD 卡、NVR 录影三种同时最多开两种,三种都开的话 NVR 会被自动关掉。我没装 SD 卡也没订云端,所以没遇到。

第 2 步:主机装显卡驱动和 Ollama

Docker 要能用到 NVIDIA 显卡,需要装驱动和 NVIDIA Container Toolkit(我是驱动 580、Toolkit 1.19)。装完先确认容器看得到显卡:

bash
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
docker run --rm --gpus all ubuntu nvidia-smi

Ollama 直接装在主机上,再拉视觉模型:

bash
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen3-vl:8b-instruct
sudo systemctl edit ollama

systemctl edit 打开的档案里加这两行,第一行让容器连得到,第二行让模型常驻在 VRAM(原因在后面资源那节):

ini
[Service]
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_KEEP_ALIVE=-1"

这里有个坑:有开 ufw 的话,Frigate 容器还是连不到 Ollama。Ollama 是主机上的行程,容器连它的流量会被 ufw 预设的 deny 挡掉;反过来 MQTT 跑在容器里、埠是 Docker 发布的,会绕过 ufw,所以会出现「MQTT 通、Ollama 逾时」的状况。解法是只放行 Frigate 的 Docker 网段(网段在下面的 compose 里钉死):

bash
sudo ufw allow from 172.28.0.0/24 to any port 11434 proto tcp
第 3 步:架 MQTT broker

我用 Mosquitto。一开始是借用之前给 ESP32 开的那个 broker,只多加两个帐号;后来有一次主机重开,那个专案的其他服务都回来了,只有 broker 没起来,Frigate 的实体在 HA 上「不可用」了 15 个小时才发现。所以现在独立成一个 compose,不跟任何专案绑在一起:

yaml
# mosquitto/compose.yml
services:
  mosquitto:
    image: eclipse-mosquitto:2
    container_name: mosquitto
    restart: unless-stopped
    ports:
      - "1883:1883"
    volumes:
      - ./config:/mosquitto/config
      - ./data:/mosquitto/data
text
# mosquitto/config/mosquitto.conf
listener 1883
allow_anonymous false
password_file /mosquitto/config/passwd
persistence true
persistence_location /mosquitto/data/

建两个帐号,一个给 Frigate、一个给 HA(第一个指令的 -c 会建立新档,第二个不要加):

bash
docker run --rm -it -v ./config:/mosquitto/config eclipse-mosquitto:2 \
  mosquitto_passwd -c /mosquitto/config/passwd frigate
docker run --rm -it -v ./config:/mosquitto/config eclipse-mosquitto:2 \
  mosquitto_passwd /mosquitto/config/passwd hass

帐号是在 broker 已经跑起来之后才加的话,要 docker compose restart mosquitto,新帐号才会生效。

第 4 步:汇出侦测模型(RF-DETR)

Frigate 不内附 NVIDIA 用的模型,要自己汇出。0.17 在 amd64 上已经拿掉 TensorRT 侦测器,照旧文件设 type: tensorrt 会直接报错要你改用 ONNX;映像选 -tensorrt 版,ONNX 就会自动用显卡跑。

模型我选 RF-DETR Nano,因为它支援 0.17 对 NVIDIA 的 CUDA Graphs。官方文件给的汇出指令如下,唯一改的地方是先装 CPU 版的 PyTorch:官方那行会拉 CUDA 版,下载好几 GB,但汇出 ONNX 根本用不到显卡。

bash
docker build . --build-arg MODEL_SIZE=Nano --rm --output . -f- <<'EOF'
FROM python:3.12 AS build
RUN apt-get update && apt-get install --no-install-recommends -y libgl1 && rm -rf /var/lib/apt/lists/*
COPY --from=ghcr.io/astral-sh/uv:0.10.4 /uv /bin/
WORKDIR /rfdetr
# 先装 CPU 版 torch,下载量小很多
RUN uv pip install --system torch==2.8.0 --index-url https://download.pytorch.org/whl/cpu
RUN uv pip install --system rfdetr[onnxexport] onnx==1.19.1 transformers==4.57.6 onnxscript
ARG MODEL_SIZE
RUN python3 -c "from rfdetr import RFDETR${MODEL_SIZE}; x = RFDETR${MODEL_SIZE}(resolution=320); x.export(simplify=True)"
FROM scratch
ARG MODEL_SIZE
COPY --from=build /rfdetr/output/inference_model.onnx /rfdetr-${MODEL_SIZE}.onnx
EOF

跑完会得到约 108MB 的 rfdetr-Nano.onnx,改名成 rfdetr.onnx 放进 config/model_cache/。这个资料夹如果是 Frigate 第一次启动时建的,拥有者会是 root,要用 sudo 复制。我实测这颗模型在 RTX 5060 Ti 上用 CUDA Graphs 推论 5.5ms,用 CPU 跑是 216ms。

第 5 步:Frigate 的 compose.yml
yaml
services:
  frigate:
    image: ghcr.io/blakeblackshear/frigate:0.17.2-tensorrt
    container_name: frigate
    restart: unless-stopped
    shm_size: 1g                 # 侦测用的影格放这里,太小会直接 crash
    ports:
      - "127.0.0.1:5000:5000"    # 没有认证的 API,只给同一台的 HA 用
      - "8971:8971"              # 有登入页的网页介面
    volumes:
      - /etc/localtime:/etc/localtime:ro
      - ./config:/config
      - /mnt/hdd/frigate/media:/media/frigate
    tmpfs:
      - /tmp/cache:size=1g       # 录影暂存放记忆体,不要一直写 SSD
    env_file: .env
    environment:
      - TZ=Asia/Taipei
    mem_limit: 10g
    deploy:
      resources:
        reservations:
          devices:
            - driver: nvidia
              count: 1
              capabilities: [gpu]
    devices:                     # 明列装置档,见下方说明
      - /dev/nvidia0:/dev/nvidia0
      - /dev/nvidiactl:/dev/nvidiactl
      - /dev/nvidia-uvm:/dev/nvidia-uvm
      - /dev/nvidia-uvm-tools:/dev/nvidia-uvm-tools
      - /dev/nvidia-modeset:/dev/nvidia-modeset

networks:
  default:
    ipam:
      config:
        - subnet: 172.28.0.0/24  # 钉死网段,ufw 规则才不会失效

.env 放帐密,Frigate 的设定档可以用 {FRIGATE_变数名} 引用以 FRIGATE_ 开头的环境变数,config 里就不会出现明码:

bash
FRIGATE_RTSP_USER=摄影机帐户
FRIGATE_RTSP_PASSWORD=摄影机帐户密码
FRIGATE_MQTT_USER=frigate
FRIGATE_MQTT_PASSWORD=刚刚设的密码

几个数字是踩过才调的:

  • shm_size:侦测改跑主串流之后,Frigate 自己回报最少要 204MB,预设的 256m 太紧,我直接给 1g 留余裕。改完要 docker compose up -d --force-recreate,只 restart 不会生效。
  • mem_limit:一开始给太少(4g、后来 6g),录影写入的档案快取一直把容器推到上限,串流被限流断线,拉到 10g 才稳。
  • tmpfs:Frigate 会先把 10 秒一段的片段写进 /tmp/cache,再决定要搬到硬碟还是丢掉。这个暂存如果留在容器的档案系统里,等于 24 小时对系统碟写入再删除,所以放记忆体。这是我当初跟保留天数一起调的,目的就是不想「疯狂写入」。
  • 8971 是 Docker 发布的埠,一样会绕过 ufw,区网里的装置都连得到。Frigate 0.17.2 修了 6 个安全公告,还有 3 个没修,前提都是存在 viewer 角色的帐号(viewer 读得到 log,而 log 里有自动产生的 admin 密码),所以我只留一个 admin,不建 viewer。另外 -tensorrt 映像有 12.9GB,第一次拉要等一下。
  • devices:只靠 deploy 要显卡的话,主机哪天执行 systemctl daemon-reload(装套件时常会自动触发),容器会悄悄失去 GPU,等 ffmpeg 重开时才爆。明列装置档就不会被拿掉。
第 6 步:Frigate 的 config.yml

这是我现在客厅镜头的设定,精简过但可以直接跑。motion 和 review 那几行的原因在下一节,prompt 的写法在「选本机视觉模型」那节。

yaml
mqtt:
  enabled: true
  host: 172.28.0.1               # 从容器连主机上的 broker(Docker 网段的 gateway)
  port: 1883
  user: '{FRIGATE_MQTT_USER}'
  password: '{FRIGATE_MQTT_PASSWORD}'

detectors:
  onnx:
    type: onnx

model:
  model_type: rfdetr
  width: 320
  height: 320
  input_tensor: nchw
  input_dtype: float
  path: /config/model_cache/rfdetr.onnx
  # rfdetr 用内建的 COCO 91 类标签,不要设 labelmap_path

ffmpeg:
  hwaccel_args: preset-nvidia    # 用显卡解码

go2rtc:                          # Frigate 内建的串流转发,镜头只被拉一次
  streams:
    keting:
      - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@镜头IP:554/stream1
    keting_sub:
      - rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@镜头IP:554/stream2

genai:
  provider: ollama
  base_url: http://172.28.0.1:11434
  model: qwen3-vl:8b-instruct

semantic_search:                 # 让 Explore 页可以用文字搜事件,只索引开启后的新事件
  enabled: true
  model: jinav1
  model_size: large

review:
  alerts:
    labels: [person]             # 只有人算 alert,猫留在 detections
  genai:
    enabled: true
    preferred_language: Traditional Chinese
    detections: true
    image_source: recordings
    activity_context_prompt: |
      这是台湾一般住家的客厅监视器,不是防盗摄影机。
      (完整内容见下面「选本机视觉模型」那节)

audio:
  enabled: true
  listen: [meow, hiss, caterwaul, speech, scream, yell, fire_alarm]

cameras:
  keting:
    ffmpeg:
      inputs:
        - path: rtsp://127.0.0.1:8554/keting
          input_args: preset-rtsp-restream
          roles: [record, detect]
        - path: rtsp://127.0.0.1:8554/keting_sub
          input_args: preset-rtsp-restream
          roles: [audio]         # 音讯另开一条连线,挂子串流省频宽
    detect:
      width: 2304                # 0.17 要写死;跑主串流的原因见「分辨两只猫」
      height: 1296
      fps: 5
    motion:
      mask:
        - 0,0,0.38,0,0.38,0.075,0,0.075
      contour_area: 25
    objects:
      track: [cat, person]       # 没养狗,dog 拿掉(侦测器会把猫认成狗)
      genai:                     # 物件描述用英文,给语意搜寻
        enabled: true
        objects: [cat, person]
        object_prompts:
          cat: >-
            Frames from an indoor living-room camera.
            In ONE short sentence, state what the cat is doing.
            If two cats are present, say so.
            Do NOT describe coat colour, pattern or markings.
            Start directly with "A cat" or "Two cats". No preamble.
            If no cat is visible, reply exactly: nothing notable.
    record:
      enabled: true
      continuous:
        days: 3
      motion:
        days: 14
      alerts:
        retain:
          days: 30
      detections:
        retain:
          days: 14
    snapshots:
      enabled: true
      bounding_box: true
      retain:
        default: 14

docker compose up -d 之后,网页介面在 https://主机IP:8971(自签凭证,浏览器会警告)。之后只改 config.yml 的话,docker compose up -d 什么都不会做(compose.yml 没变),要用 docker compose restart frigate。

admin 的初始密码只会在第一次启动时印在 log 里一次,一启动就马上记下来:

bash
docker logs frigate 2>&1 | grep -i password

log 被轮替洗掉(多重启几次就会)或容器被重建(前面改 shm_size 要 --force-recreate)之后,这组密码就找不回来了。真的找不到的话,在 config 加 auth: { reset_admin_password: true } 重启一次拿新密码,记得改回 false,不然每次重启都会换一组新的。第一次启动时 log 可能会出现一次 Detection appears to be stuck,那是 CUDA Graph 第一次捕捉超过 watchdog 的 10 秒门槛,自己会恢复。

第 7 步:接进 Home Assistant

我的 HA 是 Docker 版、用 host 网路,跟 Frigate 在同一台;下面用到 HACS,没装过的话要先照 HACS 官方文件装好。

  1. 先加 MQTT 整合:设定 → 装置与服务 → 新增整合 → MQTT,填 broker 的位址和 hass 帐号。我一开始漏了这步,结果 Frigate 的实体全部是「不可用」,只有走 HTTP API 的几个 sensor 有值,看起来很像 Frigate 坏掉。
  2. 装 Frigate 整合:从 HACS 搜寻 Frigate 安装,重启 HA 后新增整合,网址填 http://127.0.0.1:5000。我的 HA 是 host 网路、跟 Frigate 在同一台,所以直接走 loopback 上那个没认证的 API。装好会多出几十个实体,像是 sensor.keting_cat_count(画面里几只猫)、binary_sensor.keting_hiss_sound(侦测到哈气)。
  3. 打开 GenAI 开关:Frigate 整合会建立两个 GenAI 的开关(物件描述、review 摘要),预设是停用的,要手动启用再重启 HA。另外整合提供的开关和数值实体(像是 number.keting_contour_area)是执行时的覆写,会盖掉 config 里的值,调了 config 没效果的话先看这里。
  4. 回看介面:同样从 HACS 装 Advanced Camera Card,可以在 HA 里看即时画面、事件缩图和时间轴。

如果你是先装 Frigate 整合、后来才补 MQTT,要回去重新载入一次 Frigate 整合,它才会吃到 MQTT。

Advanced Camera Card 我调了几个地方:

  • 选单预设是收起来的,进了时间轴会找不到路回即时画面,我第一次只能重新整理页面。改成 menu.position: outside,再设 view.default_reset 让它闲置 120 秒自动回到即时画面。
  • triggers.reviews 预设只有 high,在我的设定里等于只有人;加上 medium,猫的事件才会自动跳出来。
  • 如果 go2rtc 的 API 只绑在 loopback,浏览器连不到,即时画面要改用 live_provider: webrtc-card(搭配 AlexxIT 的 WebRTC 卡片)。
  • 在 sections 版面里给它 column_span: 2,画面才不会太小。

通知走 ntfy 还是 HA App

我自己用 Android,通知原本走自架的 ,可以附影片、设优先级、加按钮。但家人用 iPhone 就收不到:iOS 不允许 App 在背景保持长连线,自架的 ntfy 没有设定转发到官方伺服器(upstream)的话,通知不会即时送达,官方文件写的是可能延迟好几个小时。家人手机本来就有装 HA 的 App,它走的是苹果的 APNs,所以 iPhone 的通知全部改走 HA App。如果你家只有一种手机,选一种就好,HA App 最省事。

用 ntfy 的话有两个小坑:新开的主题手机 App 要另外订阅,不然测试通知怎么发都没反应;附件大小上限是 server 设定档自己设的,影片太大会被挡。

通知附上「刚才的画面」

光写「客厅侦测到哈气」其实看不出发生什么事,所以我让通知附上一句 GenAI 摘要。麻烦的是摘要要等那段 review 结束后才会产生,警报触发的当下还没有;而火灾警报、尖叫这种通知又不能为了等文案延迟。最后的做法是写一支小脚本,抓最近 10 分钟内已经有摘要的那一则,没有就留空:

python
# /config/diary/recent_summary.py:印出最近一则有摘要的片段,没有就印空字串
import json, sys, time, urllib.request

after = int(time.time()) - int(sys.argv[1]) * 60
revs = json.load(urllib.request.urlopen(
    f"http://127.0.0.1:5000/api/review?after={after}&cameras=keting&limit=100"))
done = [r for r in revs if (r["data"].get("metadata") or {}).get("title")]
if done:
    r = max(done, key=lambda r: r["start_time"])
    m = r["data"]["metadata"]
    t = time.strftime("%H:%M", time.localtime(r["start_time"]))
    print(f"{t} {m.get('shortSummary') or m['title']}"[:90])
yaml
# configuration.yaml
shell_command:
  frigate_recent_summary: "python3 /config/diary/recent_summary.py 10"

哈气、嚎叫通常会持续一阵子,这样抓到的多半就是同一起事件。下面是「侦测到猫哈气」那条自动化的简化版:

yaml
- id: cat_hiss_alert
  alias: 客厅侦测到猫哈气
  mode: single
  triggers:
    - trigger: state
      entity_id: binary_sensor.keting_hiss_sound
      from: "off"
      to: "on"
  actions:
    - action: shell_command.frigate_recent_summary
      response_variable: ctx
    - variables:
        scene: "{{ (ctx.stdout | default('', true)) | trim }}"
    - action: notify.mobile_app_你的手机
      data:
        title: 客厅侦测到哈气
        message: >-
          {{ now().strftime('%H:%M') }},画面里有
          {{ states('sensor.keting_cat_count') }} 只猫。
          {%- if scene %} 刚才的画面:{{ scene }}{%- endif %}
        data:
          image: /api/camera_proxy/camera.keting   # 附上当下的画面
          url: /lovelace/living                    # 点通知打开客厅页
          push:
            interruption-level: time-sensitive     # 穿透 iOS 专注模式

脚本输出的是一行文字,例如我写这段时实际跑出来的:「15:41 两只猫分别在沙发和猫塔猫窝内休息,环境安静无异常活动。」这行会直接接在通知内文的「刚才的画面:」后面。

跑起来之后要调的两个地方#

上面的 config 已经包含这两个修正,这里说明为什么要改。

第一个是 motion 几乎一直在触发。我统计了一下,2412 个 10 秒录影片段里有 2348 个被判定为有动作(97.3%),但真的有物件的只有 6.3%。原因有两个:画面左上角烧录的时间戳每秒都在变,占了静止画面八成以上的变动像素;另外每 2 到 4 秒会有一次全画面的压缩杂讯。这不只浪费 GPU,record.motion.days: 14 等于连续录了 14 天。

yaml
motion:
  mask:
    - 0,0,0.38,0,0.38,0.075,0,0.075   # 遮掉左上角的时间戳
  contour_area: 25                    # 滤掉压缩杂讯,猫的面积大约 500

改完之后 motion 比例降到 31%,画面没动静时侦测器的 fps 是 0。

第二个是 GenAI 摘要预设不会帮猫写。Frigate 的 review 分成 alerts 和 detections,猫属于 detections,而 review.genai.detections 预设是 false:

yaml
review:
  genai:
    enabled: true
    detections: true          # 预设 false,猫的事件不会有摘要
    image_source: recordings  # 预设用 180p 的预览图,会漏掉人和猫

image_source 也改成用录影(480p)。用预设的预览图时,有一段明明侦测到人(信心 0.96),摘要却写「客厅内完全静止」。

选本机视觉模型#

我用同一批画面比了三个模型,画面里实际是两只虎斑猫,另外放了几张空画面当幻觉对照组:

模型猫的数量花色人/猫中文耗时
gemma3:4b两只 ✓✗常搞错繁体,但会编东西1.6s
gemma3:12b漏一只✗把人写成猫繁体,内容空泛2.9s
qwen3-vl:8b两只 ✓虎斑 ✓全对繁体,JSON 干净4.4s

最后用 qwen3-vl:8b。测的过程中也发现几个 Frigate 预设值的问题:

  • 预设的物件 prompt 是「分析这组包含 {label} 的图片」,等于先告诉模型画面里一定有那个东西,空画面也会被描述出一只猫。
  • 只设 preferred_language: Traditional Chinese 挡不住简体。预设的 review 摘要写出了「花盆从沙发搬运到凳子上」这种简体句子,还自己编了一个人名「Joe」。一开始我以为是 gemma3:4b 不会写繁体,后来发现是 Frigate 内建 prompt 的语言指令太弱,模型本身写得出繁体。要在自己的 prompt 里明写「一律使用繁体中文」和「禁止编造人名」。
  • 预设 prompt 是给室外防盗用的,会提车道、包裹、深夜的陌生人,跟客厅完全不搭。
  • 物件描述预设是英文,而且每则都用「Here's an analysis of the cat's behavior based on the images...」这种废话开头,所以 prompt 里才要写 No preamble。

我把 prompt 改成符合我家的情境:这是台湾住家的客厅,不是防盗;固定物件只有沙发、茶几、猫塔、猫砂盆、纸箱、电风扇和空气清净机;猫塔上是封闭式的圆形猫窝;家里没有养狗;不要描述毛色、不要编人名、不要说持续了几秒。

最后那几条都是被幻觉逼出来的,而且是我自己翻日记时看出来的:我家根本没有笼子。第一版跑了几天之后,摘要里出现过一天 14 次「猫在笼子里」(其实是猫塔上的圆形猫窝)、「狗」,还有一则「猫低头干呕,持续约 136 秒」,136 秒其实是那段录影的总长度,模型只看了其中几张影格。「狗」不全是视觉模型的错:Frigate 的侦测器本身一天会把猫认成狗 8 次左右(信心 0.77 到 0.95),所以我直接把 dog 从 objects.track 拿掉。

我现在用的完整 prompt(activity_context_prompt)
yaml
review:
  genai:
    activity_context_prompt: |
      这是台湾一般住家的客厅监视器,不是防盗摄影机。
      - 住户(成人)与两只猫在此活动,两者进出都是完全正常的事。
      - 没有车道、门廊、包裹等室外元素,不要提到这些。
      - 画面里固定会出现的东西只有这些:沙发、茶几、矮凳、猫塔、
        猫砂盆、纸箱、立式电风扇、空气清净机。
      - 猫塔上有一个封闭式的圆形猫窝。那是猫窝,不是笼子,家里没有任何笼子。
      - 画面里只拍得到其中一个猫砂盆(家里有两个)。
        「进入猫砂盆内、蹲踞或拨砂」才写成使用猫砂盆;
        只是走过旁边、在旁边休息,就写「在猫砂盆旁」。
      - 家里没有养狗。侦测结果若标成 dog 那是误判,不要在描述里提到狗。
      - 一律使用繁体中文(台湾用语)。
      - 禁止臆测或编造人名。未知人物一律写「一名成人」或「住户」。
      - 只描述画面中确实看得到的东西,看不清楚就说看不清楚。
      - 不要描述猫的毛色或花纹,统一写「一只猫」。
      - 禁止声称任何行为「持续了几秒/几分钟」。你看到的是抽样出来的
        少数几张影格,不是连续影片。
      - 不要用「全程」「整段时间」「持续」这类字眼。
      - Level 0(正常):住户活动、猫的任何活动、日常家事。这是绝大多数情况的正确答案。
      - Level 1(可疑):不认识的人在屋内;猫明显异常(持续呕吐姿势、抽搐、
        长时间倒地不动);物品翻倒。
      - Level 2(危险):破坏、打斗、火烟、有人跌倒后无法起身。

Frigate 的 GenAI 其实有两个地方会用到模型,我让它们写不同语言:

  • review 摘要(review.genai):一段时间的事件写成标题和摘要,用繁体中文,给通知和日记用。
  • 物件描述(objects.genai):每个追踪到的物件写一句描述,存进语意搜寻的索引。语意搜寻用的 Jina CLIP v1 只支援英文(v2 才支援多语言),所以这边的 prompt 用英文写,要求一句话、不准描述毛色、没看到猫就回 nothing notable。

这样两边各用各的语言,不需要另外做翻译。

改完 prompt 之后还有一个问题:旧的摘要已经写进资料库了。我先用同样的影格比对新旧 prompt,确定新版真的不会再写出笼子和假秒数,才去重写旧的。例如 21:27 那段,旧版写「猫咪疑似不适,持续低头干呕」,新版是「一只猫在立式电风扇旁的纸箱边走动」。Frigate 只有重新产生「物件描述」的 API(PUT /api/events/<id>/description/regenerate),review 摘要没有;网页上的 Reindex 也只是拿现有的文字重算向量,不会重写。所以我另外写了一支脚本,从录影抽影格、套用新的 prompt、用同一颗模型重写,结果存在旁边的档案里,日记读的时候优先用它,Frigate 的资料库不动。只挑有幻觉迹象的重写,721 则里挑出 72 则,每则约 3.6 秒。几天后再抽查,幻觉从约 10% 降到 2.5%(1238 则里 31 则),剩下的多半是「全程」「整段时间」这种用词。

分辨两只猫#

这是唯一没做成的部分。aki 和 kuro 是同一胎的兄妹,两只都是虎斑,一只偏暖棕、一只偏深灰,aki 比较深一点,连我自己看监视器画面都常常认错。

先查了别人怎么做#

动手之前我先查了一轮,想知道「分辨长得一样的猫」到底做不做得到。

最简单的方法是戴不同颜色的项圈,这样问题就从「认出是哪只猫」变成「找画面里的红色色块」。可惜牠们不喜欢戴项圈,这条路直接放弃。家里的宠物空气清净机(就是影片里猫坐在上面那台)会量猫的体重,我也查过能不能拿来分:两只体重不一样,谁坐上去就知道是谁。结果它的 API 不回传体重,也是死路。

学术上这叫个体重识别(re-identification),论文其实不少:

  • 一篇用中国「哈啰街猫」喂食站画面做的研究,用 2,796 张照片、69 只猫训练 Siamese 网路,最好的组合(VGG16 加 contrastive loss)测试准确率达到 97%。
  • 另一篇比较了 ResNet、DenseNet、EfficientNet、ConvNeXt 和 Siamese 网路认个别的猫,结论是用迁移学习训练的传统 CNN 表现最好。
  • 我看的几篇研究有一个共同点:毛色是最不可靠的特征,有用的是脸和身侧条纹的形状。鼻纹虽然像指纹一样独一无二,但要微距才拍得到;Double Take、CompreFace 这类现成的工具只认人脸。

商用产品也没有比较好。Furbo 的多宠辨识(Pet ID)要订阅才能用,作法是帮每只宠物上传一张看得到脸和部分身体的照片;官方说明里没有任何准确率数字,也没提到长得像的宠物怎么办。App Store 的评论倒是写得很直接:有人说每只都建了档案、而且「长得一点都不像」,它还是分不清楚;也有人说它一直把公猫叫成母猫、跟另一只猫或狗搞混,大概一半的时间不准。长得完全不像的都会认错,同一胎、花色一样的两只,我不觉得会比较好。

这些研究的准确率都很漂亮,但前提是画面够近、够清楚,而且大多是在很多只花色各异的猫里挑出一只。我的情况刚好相反:只有两只、同一胎、花色一样,镜头还在 3 公尺外。所以最后决定先用 Frigate 内建的功能试,不要一开始就自己搭训练流程。

先排除:让视觉模型认花色#

qwen 的摘要里有时写「黑猫」、有时写「虎斑猫」,看起来好像可以直接拿来分。但同一个事件,前一秒写「两只都是虎斑」,下一秒写「一只虎斑、一只黑猫」。我把 33 个事件依框的位置和亮度统计:被说成黑猫的 6 次,全部出现在画面右侧门口那一带,也就是逆光的位置,亮度并没有比较暗。模型读到的是位置和光线,不是猫本身,所以后来把花色从 prompt 里拿掉了。

Frigate 0.17 的自订分类#

Frigate 0.17 有内建的物件分类功能,可以对侦测到的猫再做一次分类,结果写进 sub label。官方文件的范例刚好就是分两只猫。

yaml
classification:
  custom:
    cats:
      enabled: true
      name: cats
      threshold: 0.8
      object_config:
        objects: [cat]
        classification_type: sub_label

建立的流程在 Frigate 网页的 Classification 页:取名、选物件 cat、类型 sub_label、类别填 aki 和 kuro,然后逐类挑训练图,没选到的会自动归到 none。类别要一个一个分开加,我第一次把「aki / kuro」打成同一个类别,挑图画面的标题就变成「Select all images with: aki / kuro」。

挑图的时候我发现很多张连我自己都认不出是谁。训练图固定是 224×224,而侦测原本跑在子串流 1280×720 上,裁出来的猫短边只有约 187px,是被放大到 224 的。把侦测改到主串流 2304×1296 之后,裁切变成约 241px,看得到脸的轮廓和身侧的条纹:

改主串流的代价是 shm_size 要拉大(第 5 步那个 1g 就是这样来的),侦测吃的像素量变成 3.24 倍,不过 motion 比例没有回升(31% 到 34.5%)。

几个用起来才知道的规则:

  • 训练图只能从 Frigate 自己裁的图里挑,不能上传自己拍的照片。维护者的说法是裁切方式要跟实际侦测时一致。
  • 训练跑在 CPU 上,通常 1 到 3 分钟。按下训练后网页上的转圈有时会一直转,那是状态没更新,不是卡住。
  • 指派名字的规则:每次分类信心要超过 threshold(我设 0.8),而且至少 3 次判断里有 60% 是同一个名字,才会写进 sub label。

WARNING

训练完的模型不一定会自动载入。log 里没看到 Successfully loaded updated model 那行的话,新模型其实还没上线,重启 Frigate 才会载入。

前后训练了几次。第一版只有 24 张(aki 11、kuro 3、none 10),上线后模型标出来的 aki 和 kuro 比例是 3.7 比 1,刚好等于训练集的 11 比 3,它学到的是比例而不是猫。最后训练集是 aki 188 张、kuro 204 张、none 41 张。

怎么知道模型准不准#

模型会帮每只猫标上名字,但标得对不对,我自己肉眼也判断不了。后来用的方法是同框:两只猫同时出现在画面上时,模型给牠们的名字一定要不一样。这是不需要人工判断的标准答案。

实际算的时候有个坑:Frigate 有时会把同一只猫追丢又重新抓到,变成两个物件,看起来像同框。所以框重叠很多(IoU 大于 0.3)的配对要排除,几乎不重叠的才算真的两只。

一开始扣掉追丢重抓的,真正的同框只剩 2 组,根本无法判断。等到 9 月 5 日累积到 34 组真正的同框,被标成不同名字的只有 13 组,38%。两只猫被指派的比例大约一半一半,所以乱猜的期望值是 50%,模型比乱猜还差。

再拿模型去跑它自己的训练集,正确率是 96.2%。训练集 96%、没看过的资料 38%,代表它是把训练图背起来了。原因在训练资料本身:392 张图其实只来自大约 30 个事件。我挑图的方式是在网页上点一个追踪物件,把它的裁切图整批收进去,所以同一个事件连续收了 5 到 20 张几乎一样的图,有效的样本数只有 30 左右。

训练集还有另一个偏差:C210 晚上会切成红外线黑白画面,颜色线索整个消失。aki 的 188 张里有 138 张是黑白的(73%),kuro 只有 26%。aki 本来就比较深,我在标的时候,暗暗的图就都归给了牠。我在 Recent 分页看到的也是这样:大部分都被标成 aki,只有明亮清楚的才认得出 kuro。不过模型在 kuro 的黑白图上还是有 92.5% 标对,所以主因还是背答案,不只是「看到黑白就猜 aki」。Frigate 官方的建议也写得很直接:多样性比数量重要,一次选几十张几乎相同的图,是让模型变差最快的方式之一。

我也试了一个不用标注的方法:Frigate 为了语意搜寻,已经帮每只侦测到的猫存了一份 CLIP 向量。拿 171 组同框配对去看这些向量能不能把两只分开,结果是 56.1%,只比乱猜好一点。

结论:瓶颈是镜头#

客厅的镜头离猫大约 3 公尺,猫脸只占画面宽度约 6%。两只花色一样的猫,在这个距离下能分辨的特征太少,换模型或多标几百张图也不太会改善。比较可行的方向是近距离的镜头,例如放在喂食区、离猫 50 公分的那种。

目前的处理是把 aki 和 kuro 的次数从日记和通知里拿掉,页面上挂一张警示卡。等喂食器那颗近拍镜头的资料累积够了,再用同样的同框方法重新验证。

猫咪日记#

日记是这次最常用的功能。每个事件 Frigate 会产生一段摘要,我在 HA 上做了一个独立页面,每 30 分钟重新整理一次,晚上 10 点推播当天的版本:

架构很单纯:HA 每 30 分钟透过 shell_command 执行一支 Python 脚本,它从 Frigate 的 /api/review 捞当天的事件和摘要,整理成一份 JSON 存在 HA 的 config 目录;HA 再用一个 sensor 读这份 JSON,页面用 Markdown 卡片排版。晚上 10 点的推播读的也是同一份,页面和通知的数字才不会对不上。

做的过程中遇到几个问题:

  • 一开始整份日记是在 HA 的 script 里用模板算的,资料一多就撞到 HA 模板输出 256KB 的上限(Template output exceeded maximum size of 262144 characters)。后来改成一支 Python 脚本产生 JSON,HA 只负责读。
  • Frigate 0.17 会把通过自订分类的物件标成 cat-verified,我原本用 "cat" in objects 筛选,结果静默漏掉了 66% 的猫事件。
  • 页面最上面要嵌当天的精华影片,结果换了新影片浏览器还是播旧的。原因是 HA 的 /local/ 回应带了 31 天的快取标头,强制重新整理也没用。最后在影片网址后面加上档案修改时间当版本号(?v= 加 mtime)才解决。影片直接用 Markdown 卡片里的 video 标签嵌,宽度要写成 width="100%" 属性,写 style 会被 HA 的白名单剥掉。精华影片晚上 9 点 50 分才产生,一天有 22 小时「今天的影片」不存在,所以没有今天的就退回显示最近一支。
  • /local/ 底下的档案不需要登入就能读,所以放影片的资料夹我用一串随机字元当名字,等于拿网址当密码。
  • 推播的部分,家人的 iPhone 走 HA App(原因见第 7 步),日记设成被动通知,不会一直跳出来;异常声音设成时效性通知。

除了日记,我也加了几个 Furbo 没有的小指标:跟过去 7 天同时段比较的活动量、客厅有人陪猫的时间、太久没看到猫的提醒。最后这个一开始只设固定 4 小时,我想到的问题是:如果牠们跑去房间睡呢?我只有客厅这一颗镜头。所以门槛改成过去最长空窗的两倍、至少 4 小时,每 30 分钟检查一次,通知文案也写明「只有客厅这一颗镜头」。猫砂盆的部分只拍得到两个之一,而且模型分不出是「经过」还是「使用」,所以只显示「接近猫砂盆」的次数当趋势参考。

每日精华影片#

这是照 Furbo 最受欢迎的功能做的:每天晚上 9 点 50 分,从当天的事件里挑片段,剪成一支加速 1.6 倍的影片,放在日记页最上面。Furbo 的日记只取早上 7 点到晚上 7 点,我没有照做,因为我家的猫半夜最活跃,凌晨 2 点、3 点各有三十几个事件。

第一版剪出来我看了之后觉得不对:晚上两只猫一起睡在沙发上、睡到翻肚的片段没被剪进去,它到底是怎么挑的?查下去有两个问题。一是回圈照小时递增、选满 10 段就停,所以永远只会选到凌晨到早上 9 点;二是排序用的是侦测信心,而猫的信心几乎全部在 0.98 到 0.99 之间,排序等于随机。

修完之后先试了一版复合评分,两只猫同框加权、片段长度加权。但我觉得同框不是重点,精华应该是比较可爱、比较有事发生的片段,而真正知道「这段在干嘛」的资讯,只存在 GenAI 写的那句摘要里。所以改成让同一颗 qwen3-vl:8b 根据自己写的摘要,替每个事件打 1 到 5 分:玩耍、追逐、理毛、伸展、两只互动、跟人互动是 5 分,静止不动是 1 分。这里也有几个坑:

  • Ollama 的 format: "json" 只保证输出是合法的 JSON,不保证格式。模型有一次自己吐了一份保全报告格式的 JSON。改用 structured outputs 指定 JSON Schema 才稳定。
  • 一次丢 95 则让 8B 模型排序,它只会回前 10 个。改成每批 20 则、逐则打分。

整支脚本是 Python,每天 21:50 由 cron 执行,核心是三个 API 呼叫。下面是简化过的版本:

python
import json, urllib.request

FRIGATE = "http://127.0.0.1:5000/api"
OLLAMA = "http://127.0.0.1:11434/api/generate"

# 1. 拿当天的 review 和 GenAI 摘要。参数是复数 cameras,
#    写成单数 camera 会被静默忽略,两台镜头的片段混在一起
revs = json.load(urllib.request.urlopen(
    f"{FRIGATE}/review?after={start}&before={end}&cameras=keting&limit=2000"))

# 0.17 通过自订分类的物件会变成 cat-verified,要把后缀拿掉再比
segs = [r for r in revs
        if any(o.removesuffix("-verified") == "cat" for o in r["data"]["objects"])
        and r["data"].get("metadata")]

# 2. 让同一颗模型依摘要打分,format 一定要给 JSON Schema
SCHEMA = {"type": "object", "properties": {"scores": {"type": "array", "items": {
    "type": "object",
    "properties": {"i": {"type": "integer"}, "s": {"type": "integer"}},
    "required": ["i", "s"]}}}, "required": ["scores"]}

lines = [f"{i} {r['data']['metadata']['title']}" for i, r in enumerate(segs[:20])]
body = json.dumps({
    "model": "qwen3-vl:8b-instruct",
    "prompt": SCORE_PROMPT.format(items="\n".join(lines)),  # 1 到 5 分的评分标准
    "stream": False, "format": SCHEMA, "options": {"temperature": 0.1},
}).encode()
req = urllib.request.Request(OLLAMA, data=body, headers={"Content-Type": "application/json"})
scores = json.loads(json.load(urllib.request.urlopen(req))["response"])["scores"]

# 3. 依时间范围直接切片段,不要抓整个 event 的 clip
#    (最长的事件有 2513 秒,伺服器要现场转档 42 分钟)
clip = f"{FRIGATE}/{seg['camera']}/start/{t0:.3f}/end/{t1:.3f}/clip.mp4"

拿到片段之后用 ffmpeg 加速 1.6 倍(画面 setpts、声音 atempo),最后 concat 接成一支。接的时候所有片段的编码参数要一致,所以只取客厅那颗镜头,有没有音轨也要全部统一。

跑了一周,我还是觉得精华常常怪怪的。这次不是挑错段,而是片段本身切错:每段固定撷取 9.6 秒,但很多事件比 9.6 秒短,30 段里有 9 段比撷取长度还短,有一段甚至是 0 秒。回头追为什么要固定 9.6 秒,答案是没有任何技术理由,当初随手订的。改成依事件长度变动(输出 2 到 10 秒),总长度控制在 180 秒。

最后一个问题是重复。其中一只猫很常在猫塔的圆形猫窝里睡觉,一天的精华里有好几段都是同一个猫窝:

一开始的想法是用摘要文字判断重复,但我觉得标题一样不是问题,重点是画面内容,所以实际把缩图抽出来看。结果文字相似度根本是反指标:同样的描述画面可能完全不同,不同的描述反而可能是同一个猫窝;拿整张画面比相似度也不行,会被固定的背景主导。最后用的是侦测框的中心位置加面积:中心距离小于 0.1、面积差在两倍内就算同一个位置,每个位置最多 3 段。选段分两阶段,前 70% 的时间纯看分数,后 30% 补平常比较少被选到的时段。影片变长我不在意,只怕硬碟不够,所以总长度抓 180 秒:实测每秒约 59KB,一支 10MB 左右,存 7 天不到 100MB。

现在每天产出大约 18 到 26 段、两分多钟、12MB 上下的影片,保留 7 天。下面是 10 月 12 日那支精华的其中 30 秒(原本有收音,放上来时拿掉了):凌晨一只猫从门口走进来,清晨 5 点跳上猫塔,在上面打了个大呵欠。

0:00 / 0:00
每日精华影片节录:片段由 qwen3-vl:8b 评分挑选,1.6 倍速

顺带一提,写这篇的时候我拿影片里打呵欠的那一格直接问 qwen3-vl(没有套我的 prompt),它回的是「画面中的黑猫正站在猫爬架的最上层……嘴巴张开,似乎在嘶叫」。虎斑被说成黑猫、呵欠被说成嘶叫,前面那段 prompt 要写那么细就是这个原因。

资源占用#

这套东西全部跑在家里的伺服器上,下面是 10 月 13 日实际量到的数字(那时已经多接了一台喂食器的镜头,所以解码有两条)。

VRAM 的部分,nvidia-smi 看到的是:

行程VRAM
Ollama:qwen3-vl:8b(常驻)约 6.9 GB
Frigate 语意搜寻(CLIP 向量)约 0.8 GB
Frigate 侦测器(ONNX RF-DETR Nano)约 0.45 GB
ffmpeg 硬体解码(每台镜头)约 0.3 GB
合计约 8.7 GB / 16 GB

大头是视觉模型。我让 Ollama 一直把模型留在 VRAM 里(OLLAMA_KEEP_ALIVE=-1),因为每次重新载入都要等(一开始用 gemma3:4b 时,第一则摘要等了 36 秒)。照这个数字,8GB 的显卡塞不下这个组合,12GB 以上会比较从容。精华影片的评分也是用同一颗 8B,如果改用 27B 的版本,就会把常驻的 8B 挤出 VRAM。

其他资源:

  • GPU 使用率:平常 2% 到 3%,整张卡待机约 31W。Frigate 的侦测只在画面有动作时才跑,一次约 6ms,没动静时侦测 fps 是 0。真正会让 GPU 跑满的是写摘要:我实测一则摘要约 3 秒,这段时间功耗冲到 160 到 170W。客厅每天大约 160 到 240 个事件,加起来 GPU 每天满载的时间大约 10 分钟。
  • CPU 和记忆体:docker stats 看 Frigate 整个容器是 17% 左右(100% 等于一个核心),记忆体约 5GB,容器上限我设 10GB。
  • 硬碟:客厅镜头连续录影一天约 10GB。保留天数原本规划的是连续录影 14 天、有动作的 30 天、事件 60 天,我不想占太多硬碟、也不想让硬碟整天疯狂写入,所以缩成连续录影 3 天、有动作的 14 天、事件 30 天,目前客厅的录影总共约 69GB,另外快照和事件片段约 6.7GB。精华影片一天 12MB,保留 7 天可以忽略。换句话说,录影能回看多久完全看硬碟有多大:以客厅一天 10GB 算,1TB 就能连续录大约三个月,跟 Furbo 用 3 天或 7 天分订阅等级是完全不同的思路。

跟 Furbo 比#

功能Furbo(订阅)我的做法
录影回看3 或 7 天,只有 AI 判断的片段连续录影,能存几天只看硬碟多大(我设连续 3 天、事件 30 天)
事件摘要宠物保姆观察日志本机 qwen3-vl:8b,繁中
每日精华影片有有,LLM 评分挑片段
叫声、异常声音通知有有,附上前几分钟的画面摘要
分辨每一只宠物有(多宠辨识),但评论常抱怨认错没做成,同框验证 38%
丢零食有没有
费用摄影机 1,699 起(绑约),标准方案首年 1,840 元0,用现有的镜头、显卡和硬碟

其实在验出 38% 之前,我就已经在找有镜头的自动喂食器,原本是想补客厅镜头拍不到的地板角度,顺便自动喂食。分猫失败之后它多了一个任务:猫吃饭的时候离镜头只有几十公分,刚好就是分猫需要的近拍。后来买的那台现在也接进 Frigate 了,怎么接起来、分猫有没有变准,会写在另一篇。

參考連結