🔑 重要なポイント
✦ AI·GEN筆者は広告に押されてFurboを買う寸前までいったが、台湾の公式サイトを調べると録画の見返しはすべてペットシッターのサブスク頼みで、しかもAIがペットがいると判断したクリップしか保存されないとわかった。そこでFrigateとHome Assistantで自作することにした。録画の見返し、イベント通知、毎日の猫日記とハイライト動画を、すべて自宅のサーバーで動かしている。記事ではゼロからの構築手順を一通り載せ(Tapo RTSP、Ollama、Mosquitto、RF-DETRモデルのエクスポート、Frigateのcomposeとconfig、Home Assistant統合と通知)、Frigate 0.17の調整(カメラのタイムスタンプをマスクしてmotionを97%から31%に下げる、detectionsのGenAI要約を有効にする)、3つのローカル視覚モデルの比較、そしてpromptで幻覚を約10%から2.5%に抑えた方法を記録している。唯一できなかったのは、同じ柄の虎斑猫2匹の見分けだ。Frigate内蔵のカスタム分類で学習させ、「同時に映っているときは名前が違うはず」という基準で検証すると正解率はわずか38%で、でたらめの50%を下回った。原因は、学習画像がおよそ30個のイベントからしか来ておらずモデルが学習データを丸暗記していたことと、リビングのカメラが遠すぎることだった。後半では日記ページと毎日のハイライト動画の作り方(LLMに要約をもとにクリップを採点させ、検出枠の位置で重複を避ける)を説明し、実測したリソース使用量も載せている。全体でVRAM約8.7GB、リビングのカメラの録画は1日約10GBだ。
うちには虎斑の猫が2匹いる。akiとkuroだ。猫を飼い始めてから、Furboのペットカメラの広告がずっと目の前に出てくるようになった。遠隔でおやつを投げられる、AIペットシッター、毎日自動で編集してくれる猫日記、嘔吐検知。何度も見ているうちに本気で欲しくなって、一時は注文する寸前までいった。でもちゃんと調べてみたら、私が欲しかった機能はほぼ全部サブスクに紐づいていて、しかも録画はAIがペットがいると判断した部分しか保存されないとわかった。
ちょうどリビングにはTapo C210が1台ずっと動いていた。ただSDカードが入っていないので、ライブ映像が見られるだけで録画の再生はできない。結局Furboは買わず、その機能リストを目標にして、FrigateとHome Assistantで自作することにした。録画を見返せること、猫に動きがあったら通知が来ること、毎日「今日猫が何をしたか」を自動でまとめること。2週間ほどかけた結果、録画、通知、ローカルAIが書く猫日記と毎日のハイライト動画は全部動いた。唯一できなかったのが「どっちの猫かを見分ける」こと。分類モデルを学習させて、2匹が同じ画面に映っている場面で検証したら、正解率は38%。でたらめに当てる50%より低かった。
この記事では順番に、調べてわかったFurboの弱点、ゼロからの構築手順(composeとconfigも載せたので、自分の認証情報に差し替えればそのまま使える)、どのローカル視覚モデルを選んだか、猫の見分けが使えないとどう検証したか、日記とハイライト動画の作り方、最後に全体を動かしたときのVRAMとディスクの使用量を記録する。
Furboを買いかけて、調べてわかったこと#
以下は2026年10月にFurbo台湾の公式サイトで調べた内容。
- カメラ:キャットカメラ360度版のページには「最低NT$1,699から」とあるが、それはペットシッター(寵物保姆)サブスクを同時に契約した場合の価格(最低3か月)。契約なしの単体は公式ShopeeストアでNT$5,399。電源アダプターは同梱されず、別売でNT$299。Wi-Fiは2.4GHzのみ対応。
- サブスクなしで使えるもの:ライブ映像、双方向通話、おやつ投げ、猫じゃらし、鳴き声通知。録画はどれも見返せない。
- ペットシッターサブスク:スタンダードプランは年払いで初年度NT$1,840(月払いの定価は月NT$219)、録画の保存は3日。プレミアムプランは月NT$188から、保存7日。台湾の2プランの違いは保存日数だけで、猫日記、嘔吐検知、マルチペット識別はどちらにも入っている。カメラを1台増やすと、1台あたり月NT$40が追加になる。
本当に諦めがついたのは録画方式だった。Furboの見返し機能は、AIがペットを検知したときだけ保存されるクリップで、連続録画ではない。App Store台湾の低評価レビューもこの点をめぐるものが多く、たとえば「見たい場面がたいてい録れていない」「一日中鳴き声の通知を送ってくるのに、吐いたところは検知できない」といったもの。接続が切れるのもよくある不満だった。Furbo自身のヘルプ記事にも「ペットの緊急事態」アラートの閾値が書いてある。1日に2回過度な嘔吐を検知するか、5分以内に2回痙攣を検知した場合だ。誤検知の原因としては、柄物のラグの上でうつむく、光の変化、おもちゃを置く、が挙げられていた。要するに、うつむいた姿勢の判定に、繰り返し回数の閾値を足しているように見える。うちのリビングにはもうC210があるので、Furboを買うと2.4GHzしか使えないカメラがもう1台増えて、そのうえ毎年サブスク代を払い、3日分のAIクリップを手に入れることになる。
広告があれだけ激しいのにも理由がある。台湾のメディア『數位時代』の10月のインタビューによると、Furboのサブスク収入は2023年以降ハードウェアを上回り、いまでは全体の売上の半分以上を占めている。戦略は、カメラの購入価格を抑えて、録画の保存日数でサブスクに段階をつけることだという。
とはいえ、レビューを調べていくと、みんなが一番気に入っている機能もわかった。毎日1本、5秒から1分ほどの、音楽付きのタイムラプス「猫日記」動画だ。
もともと使っていたHAのダッシュボードと比べると、違いははっきりしていた。私が作っていたのは「いま画面に猫がいるか」で、Furboが売っているのは「猫がさっき何をしていたか」。だから目標は3つに決めた。録画の見返し、イベントの要約、毎日のハイライト。
ゼロから構築する#
全体の構成はこう。
- カメラ:Tapo C210。メインストリーム2304×1296、サブストリーム1280×720、2.4GHzのみ、SDカードなし。なのでFrigateが唯一の録画手段になる。
- ホスト:24時間動いているLinuxサーバー1台。GPUはRTX 5060 Ti 16GB、録画は16TBのHDDに置く。
- 0.17.2:録画、物体検出、イベント管理。Dockerで動かす。
- Ollama:ホスト上で視覚言語モデルを動かし、イベントごとに要約を書かせる。
- broker:FrigateとHome Assistantの間で状態を受け渡す。
- Home Assistant:通知、ダッシュボード、日記ページ。
以下、実際に構築した順に7ステップに分けた。各ステップは折りたたんであるので、手順どおりにやるときに開いてほしい。設定ファイルからは認証情報とIPを抜いてあるので、自分のものに置き換えれば使える。
ステップ1:カメラのRTSPを有効にする
Tapoのカメラは、まずアプリのカメラ設定 → 詳細設定で「カメラアカウント」を作る必要がある。この認証情報は、Tapoにログインするアカウントとは別物だ。作成後のストリームURLは次のとおり。
rtsp://攝影機帳戶:密碼@鏡頭IP:554/stream1 # 主串流
rtsp://攝影機帳戶:密碼@鏡頭IP:554/stream2 # 子串流接続する前に、まず ffprobe で実際の解像度とビットレートを測っておくこと。スペック表やネットの情報をそのまま書かないほうがいい。私のC210の実測では、メインストリームが2304×1296、25fps、ビットレート約1.15 Mbpsで、1日に換算すると約11.6GB。サブストリームは1280×720、約0.15 Mbps。Frigate 0.17の解像度自動検出は間違えることがあり、公式もconfigでdetectの幅と高さを固定するよう勧めている。そこに書くのがここで測った値だ。それからTapo公式のFAQによると、クラウド保存、SDカード、NVR録画の3つは同時に最大2つまでしか有効にできず、3つとも有効にするとNVRが自動でオフになる。私はSDカードもクラウドも使っていないので、この問題には当たらなかった。
ステップ2:ホストにGPUドライバーとOllamaを入れる
DockerからNVIDIAのGPUを使うには、ドライバーとNVIDIA Container Toolkitが必要(私はドライバー580、Toolkit 1.19)。インストールしたら、まずコンテナからGPUが見えるか確認する。
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
docker run --rm --gpus all ubuntu nvidia-smiOllamaはホストに直接インストールして、視覚モデルをpullする。
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen3-vl:8b-instruct
sudo systemctl edit ollamasystemctl edit で開いたファイルにこの2行を足す。1行目はコンテナから接続できるようにするため、2行目はモデルをVRAMに常駐させるため(理由は後のリソースの節で)。
[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で固定している)。
sudo ufw allow from 172.28.0.0/24 to any port 11434 proto tcpステップ3:MQTT brokerを立てる
私はMosquittoを使っている。最初は以前ESP32用に立てたbrokerを借りて、アカウントを2つ足しただけだった。ところがあるときホストを再起動したら、そのプロジェクトの他のサービスは戻ってきたのにbrokerだけ起動せず、HA上でFrigateのエンティティが「利用不可」のまま15時間たってからやっと気づいた。それ以来、どのプロジェクトにも結びつけない独立したcomposeにしている。
# 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# mosquitto/config/mosquitto.conf
listener 1883
allow_anonymous false
password_file /mosquitto/config/passwd
persistence true
persistence_location /mosquitto/data/アカウントを2つ作る。1つはFrigate用、1つはHA用(1つ目のコマンドの -c は新しいファイルを作るので、2つ目には付けないこと)。
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 hassbrokerがすでに動いている状態でアカウントを追加した場合は、docker compose restart mosquitto しないと新しいアカウントが有効にならない。
ステップ4:検出モデル(RF-DETR)をエクスポートする
FrigateにはNVIDIA用のモデルが同梱されていないので、自分でエクスポートする必要がある。0.17ではamd64でTensorRT検出器が廃止されていて、古いドキュメントどおりに type: tensorrt と書くと、ONNXを使えというエラーがそのまま出る。イメージを -tensorrt 版にすれば、ONNXが自動でGPUで動く。
モデルはRF-DETR Nanoを選んだ。0.17のNVIDIA向けCUDA Graphsに対応しているからだ。公式ドキュメントのエクスポートコマンドは以下のとおりで、変えたのは先にCPU版のPyTorchを入れる部分だけ。公式の行はCUDA版を引っ張ってきて数GBダウンロードするが、ONNXのエクスポートにGPUはまったく要らない。
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
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に平文が出てこない。
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に書き、それからHDDに移すか捨てるかを決める。この一時領域がコンテナのファイルシステムに残っていると、システムディスクに24時間書き込んでは消すことになるので、メモリに置いた。これは保存日数と一緒に調整したもので、目的は「狂ったような書き込み」を避けることだった。8971はDockerが公開しているポートなので、これもufwを素通りし、LAN内のどの機器からでもつながる。Frigate 0.17.2では6件のセキュリティアドバイザリが修正されたが、まだ3件が未修正で、どれもviewerロールのアカウントが存在することが前提になっている(viewerはログを読めて、ログには自動生成されたadminのパスワードが出ている)。なので私はadminを1つだけ残して、viewerは作っていない。ちなみに-tensorrtイメージは12.9GBあるので、初回のpullは少し待つ。devices:deployだけでGPUを要求していると、ホストでいつかsystemctl daemon-reloadが実行されたとき(パッケージのインストールで自動的に走ることが多い)、コンテナが黙ってGPUを失い、ffmpegが再起動したときに初めてエラーになる。デバイスファイルを明示しておけば外されない。
ステップ6:Frigateのconfig.yml
これがいまのリビングのカメラの設定。簡略化してあるが、そのまま動く。motionとreviewの数行の理由は次の節、promptの書き方は「ローカル視覚モデルを選ぶ」の節で説明する。
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: 14docker compose up -d したら、Webインターフェースは https://主機IP:8971(自己署名証明書なので、ブラウザーが警告を出す)。その後config.ymlだけを変えた場合、docker compose up -d は何もしない(compose.ymlが変わっていないため)ので、docker compose restart frigate を使う。
adminの初期パスワードは初回起動時にログに一度だけ出力されるので、起動したらすぐ控えておくこと。
docker logs frigate 2>&1 | grep -i passwordログがローテーションで流れたり(何度か再起動すればそうなる)、コンテナが作り直されたり(前述の shm_size の変更には --force-recreate が必要)すると、このパスワードは二度と取り戻せない。どうしても見つからなければ、configに auth: { reset_admin_password: true } を足して一度再起動すれば新しいパスワードがもらえる。falseに戻すのを忘れないこと。戻さないと再起動のたびに新しいパスワードに変わる。初回起動時にログに一度 Detection appears to be stuck が出ることがあるが、これはCUDA Graphの初回キャプチャがwatchdogの10秒の閾値を超えたためで、自然に回復する。
ステップ7:Home Assistantにつなぐ
私のHAはDocker版でhostネットワーク、Frigateと同じマシンで動いている。以下ではHACSを使うので、入れていなければ先にHACS公式ドキュメントに従ってインストールしておく。
- まずMQTT統合を追加:設定 → デバイスとサービス → 統合を追加 → MQTT で、brokerのアドレスとhassアカウントを入力する。最初これを飛ばしたら、Frigateのエンティティが全部「利用不可」になり、HTTP APIを使う一部のsensorにだけ値が入った。Frigateが壊れたようにしか見えなかった。
- Frigate統合をインストール:HACSでFrigateを検索してインストールし、HAを再起動してから統合を追加、URLには
http://127.0.0.1:5000を入れる。私のHAはhostネットワークでFrigateと同じマシンにあるので、loopback上の認証なしのAPIに直接つないでいる。インストールすると数十個のエンティティが増える。たとえばsensor.keting_cat_count(画面に猫が何匹いるか)、binary_sensor.keting_hiss_sound(威嚇音を検出)。 - GenAIスイッチをオンにする:Frigate統合はGenAIのスイッチを2つ作る(物体の説明、reviewの要約)が、デフォルトでは無効なので、手動で有効にしてHAを再起動する。また、統合が提供するスイッチや数値エンティティ(
number.keting_contour_areaなど)は実行時の上書きで、configの値より優先される。configを調整しても効かないときは、まずここを見る。 - 見返し用のUI:同じく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アプリか
私自身はAndroidで、通知はもともと自前ので送っていた。動画を添付でき、優先度も設定でき、ボタンも付けられる。ところが家族はiPhoneなので届かない。iOSはアプリがバックグラウンドで長時間接続を保つことを許さないので、セルフホストのntfyは公式サーバーへの転送(upstream)を設定しないと、通知がリアルタイムに届かない。公式ドキュメントには、数時間遅れることもあると書かれている。家族のスマホにはもともとHAのアプリが入っていて、こちらはAppleのAPNsを通るので、iPhoneへの通知はすべてHAアプリに切り替えた。家のスマホが1種類だけなら、どちらか1つで十分。HAアプリが一番手間がない。
ntfyを使う場合の小さな罠が2つある。新しく作ったトピックはスマホアプリで別途購読しないと、テスト通知をいくら送っても反応がない。添付ファイルのサイズ上限はserverの設定ファイルで自分で決めるもので、動画が大きすぎるとはじかれる。
通知に「さっきの映像」を添える
「リビングで威嚇音を検出」と書くだけでは、実際に何が起きたのかわからない。なので通知にGenAIの要約を1文添えることにした。面倒なのは、要約はそのreviewが終わってから生成されるので、アラートが発火した瞬間にはまだないこと。かといって、火災報知器や悲鳴のような通知を、文面を待つために遅らせるわけにはいかない。最終的には小さなスクリプトを書いて、直近10分以内にすでに要約があるものを取ってきて、なければ空にすることにした。
# /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])# configuration.yaml
shell_command:
frigate_recent_summary: "python3 /config/diary/recent_summary.py 10"威嚇や鳴きわめきはたいていしばらく続くので、こうして拾えるのはだいたい同じ出来事になる。以下は「猫の威嚇音を検出」のオートメーションの簡略版。
- 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 專注模式スクリプトの出力は1行のテキストで、たとえばこの部分を書いているときに実際に出たのは「15:41 2匹の猫がそれぞれソファとキャットタワーの猫ハウスの中で休んでいる。周囲は静かで、異常な動きはない」というもの(実際の出力は繁体字中国語)。この行が、通知本文の「剛才的畫面:」(さっきの映像:)のすぐ後ろにつながる。
動かしてから調整した2か所#
上のconfigにはこの2つの修正がすでに入っている。ここではなぜ変えたかを説明する。
1つ目は、motionがほぼずっと発火していたこと。集計してみると、10秒の録画セグメント2412個のうち2348個が動きありと判定されていた(97.3%)が、実際に物体があったのは6.3%だけだった。原因は2つ。画面左上に焼き込まれたタイムスタンプが毎秒変わり、静止画面の変化ピクセルの8割以上を占めていたこと。それから2〜4秒ごとに、画面全体の圧縮ノイズが出ていたこと。GPUの無駄なだけでなく、record.motion.days: 14 は事実上14日間の連続録画になっていた。
motion:
mask:
- 0,0,0.38,0,0.38,0.075,0,0.075 # 遮掉左上角的時間戳
contour_area: 25 # 濾掉壓縮雜訊,貓的面積大約 500修正後、motionの割合は31%まで下がり、画面に動きがないときの検出器のfpsは0になった。
2つ目は、GenAIの要約がデフォルトでは猫について書かれないこと。Frigateのreviewはalertsとdetectionsに分かれていて、猫はdetectionsに入る。そして review.genai.detections のデフォルトはfalseだ。
review:
genai:
enabled: true
detections: true # 預設 false,貓的事件不會有摘要
image_source: recordings # 預設用 180p 的預覽圖,會漏掉人和貓image_source も録画(480p)を使うように変えた。デフォルトのプレビュー画像だと、人を検出している(信頼度0.96)のに、要約には「リビングは完全に静止している」と書かれたことがあった。
ローカル視覚モデルを選ぶ#
同じ画像セットで3つのモデルを比べた。画面に実際にいるのは虎斑の猫2匹で、ほかに何も映っていない画像を数枚、幻覚の対照群として混ぜた。
| モデル | 猫の数 | 毛柄 | 人/猫 | 中国語 | 所要時間 |
|---|---|---|---|---|---|
| gemma3:4b | 2匹 ✓ | ✗ | よく間違える | 繁体字、ただし話を作る | 1.6s |
| gemma3:12b | 1匹見落とす | ✗ | 人を猫と書く | 繁体字、内容が空疎 | 2.9s |
| qwen3-vl:8b | 2匹 ✓ | 虎斑 ✓ | 全問正解 | 繁体字、JSONがきれい | 4.4s |
最終的にqwen3-vl:8bにした。テストの過程で、Frigateのデフォルト値の問題もいくつか見つかった。
- デフォルトの物体promptは「
{label}を含むこの画像群を分析せよ」で、画面にそれが必ずいると最初からモデルに教えているようなもの。何も映っていない画像でも、猫が1匹描写されてしまう。 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はうちの状況に合わせて書き直した。これは台湾の住宅のリビングで、防犯用ではない。常にある物はソファ、ローテーブル、キャットタワー、猫トイレ、段ボール箱、扇風機、空気清浄機だけ。キャットタワーの上にあるのは密閉型の丸い猫ハウス。犬は飼っていない。毛の色を描写しない、人名を作らない、何秒続いたかを言わない。
最後のいくつかは、どれも幻覚に追い込まれて足したもので、しかも自分で日記を読み返していて気づいた。うちにはケージなんてない。初版を数日動かしたら、要約に「猫がケージの中にいる」が1日14回(実際はキャットタワーの丸い猫ハウス)、「犬」が出てきて、さらに「猫がうつむいてえずいている、約136秒続いた」という1件もあった。136秒は実はその録画の全体の長さで、モデルはそのうち数フレームしか見ていない。「犬」はすべてが視覚モデルのせいではない。Frigateの検出器自体が1日に8回ほど猫を犬と認識していた(信頼度0.77〜0.95)ので、dogは objects.track から外した。
いま使っている完全なprompt(activity_context_prompt)
review:
genai:
activity_context_prompt: |
這是台灣一般住家的客廳監視器,不是防盜攝影機。
- 住戶(成人)與兩隻貓在此活動,兩者進出都是完全正常的事。
- 沒有車道、門廊、包裹等室外元素,不要提到這些。
- 畫面裡固定會出現的東西只有這些:沙發、茶几、矮凳、貓塔、
貓砂盆、紙箱、立式電風扇、空氣清淨機。
- 貓塔上有一個封閉式的圓形貓窩。那是貓窩,不是籠子,家裡沒有任何籠子。
- 畫面裡只拍得到其中一個貓砂盆(家裡有兩個)。
「進入貓砂盆內、蹲踞或撥砂」才寫成使用貓砂盆;
只是走過旁邊、在旁邊休息,就寫「在貓砂盆旁」。
- 家裡沒有養狗。偵測結果若標成 dog 那是誤判,不要在描述裡提到狗。
- 一律使用繁體中文(台灣用語)。
- 禁止臆測或編造人名。未知人物一律寫「一名成人」或「住戶」。
- 只描述畫面中確實看得到的東西,看不清楚就說看不清楚。
- 不要描述貓的毛色或花紋,統一寫「一隻貓」。
- 禁止聲稱任何行為「持續了幾秒/幾分鐘」。你看到的是抽樣出來的
少數幾張影格,不是連續影片。
- 不要用「全程」「整段時間」「持續」這類字眼。
- Level 0(正常):住戶活動、貓的任何活動、日常家事。這是絕大多數情況的正確答案。
- Level 1(可疑):不認識的人在屋內;貓明顯異常(持續嘔吐姿勢、抽搐、
長時間倒地不動);物品翻倒。
- Level 2(危險):破壞、打鬥、火煙、有人跌倒後無法起身。FrigateのGenAIには、実はモデルを使う場所が2つあって、それぞれ別の言語で書かせている。
- review要約(
review.genai):一定時間のイベントをタイトルと要約にまとめる。繁体字中国語で、通知と日記に使う。 - 物体の説明(
objects.genai):追跡した物体ごとに1文の説明を書き、セマンティック検索のインデックスに入れる。セマンティック検索に使われるJina CLIP v1は英語しか対応していない(多言語対応はv2から)ので、こちらのpromptは英語で書き、1文で、毛の色は描写禁止、猫が見えなければnothing notableと返すように指示している。
これで両方がそれぞれの言語を使い、別途翻訳する必要はない。
promptを直したあとにも、もう1つ問題があった。古い要約はもうデータベースに書き込まれている。まず同じフレームで新旧のpromptを比べ、新しい版が本当にケージや嘘の秒数を書かなくなったのを確かめてから、古いものを書き直した。たとえば21:27のイベントは、旧版が「猫が体調不良の様子、うつむいてえずき続けている」、新版が「1匹の猫がスタンド扇風機のそばの段ボール箱の横を歩いている」(どちらも原文は中国語)。Frigateには「物体の説明」を再生成するAPI(PUT /api/events/<id>/description/regenerate)はあるが、review要約にはない。Web画面のReindexも、既存のテキストでベクトルを計算し直すだけで、書き直しはしない。なので別にスクリプトを書いた。録画からフレームを抜き出し、新しいpromptを適用して同じモデルで書き直し、結果は横に置いたファイルに保存する。日記は読むときにそちらを優先し、Frigateのデータベースには手を付けない。幻覚の兆候があるものだけを書き直し、721件から72件を選んだ。1件あたり約3.6秒。数日後に抜き取りチェックすると、幻覚は約10%から2.5%(1238件中31件)に下がっていて、残りの大半は「全程」「整段時間」(ずっと、終始)のような言い回しだった。
2匹の猫を見分ける#
ここが唯一うまくいかなかった部分だ。akiとkuroは同じ母猫から生まれた兄妹で、2匹とも虎斑。片方は暖かみのある茶色寄り、もう片方は濃いグレー寄りで、akiのほうが少し濃い。私自身、監視カメラの映像ではよく見間違える。
まず他の人のやり方を調べた#
手を動かす前にひととおり調べて、「見た目が同じ猫の見分け」がそもそも可能なのかを知りたかった。
一番簡単なのは色の違う首輪をつけることで、そうすれば問題は「どの猫かを見分ける」から「画面の中の赤い色の塊を探す」に変わる。残念ながら2匹とも首輪が嫌いなので、この道はすぐに諦めた。家にあるペット用空気清浄機(動画で猫が上に座っているあれ)は猫の体重を測れるので、それで見分けられないかも調べた。2匹は体重が違うので、誰が乗ったかでわかるはず。ところがそのAPIは体重を返さないので、これも行き止まりだった。
学術的には個体の再識別(re-identification)と呼ばれていて、論文は意外と多い。
- 中国の「哈囉街貓(Hello Street Cat)」の給餌ステーションの映像を使った研究では、69匹・2,796枚の写真でSiameseネットワークを学習させ、最良の組み合わせ(VGG16とcontrastive loss)でテスト精度97%に達した。
- 別の論文はResNet、DenseNet、EfficientNet、ConvNeXt、Siameseネットワークで個々の猫の識別を比べ、転移学習で学習した従来型のCNNが一番よいという結論だった。
- 読んだいくつかの研究に共通していたのは、毛色が最も信頼できない特徴で、役に立つのは顔と体側の縞の形だということ。鼻紋は指紋のように唯一無二だが、マクロでないと撮れない。Double TakeやCompreFaceのような既製のツールは人の顔しか認識しない。
商用製品もよいとは言えない。Furboのマルチペット識別(Pet ID)はサブスクでしか使えず、ペットごとに顔と体の一部が見える写真を1枚アップロードする方式。公式の説明には精度の数字がまったくなく、似ているペットの場合どうするかも書かれていない。App Storeのレビューはかなり率直で、1匹ずつプロフィールを作って、しかも「まったく似ていない」のに見分けられないという人もいれば、オス猫をずっとメス猫と呼ぶ、別の猫や犬と混同する、半分くらいの確率で外れる、という人もいた。まったく似ていない子でも間違えるなら、同じ母猫から生まれた同じ柄の2匹でうまくいくとは思えない。
これらの研究の精度はどれも見事だが、前提として映像が十分近くて鮮明で、しかも大半は柄の違うたくさんの猫から1匹を選び出すタスクだ。私の状況はちょうど逆で、2匹だけ、同じ母猫、同じ柄、しかもカメラは3メートル先。なので、いきなり自分で学習パイプラインを組むのはやめて、まずFrigateの内蔵機能で試すことにした。
まず除外:視覚モデルに毛柄を認識させる#
qwenの要約にはときどき「黒猫」と書かれ、ときどき「虎斑猫」と書かれるので、そのまま見分けに使えそうに見えた。でも同じイベントで、ある瞬間は「2匹とも虎斑」、次の瞬間は「1匹は虎斑、1匹は黒猫」と書く。33個のイベントを枠の位置と明るさで集計すると、黒猫と言われた6回はすべて画面右側の出入り口あたり、つまり逆光の位置で出ていて、明るさが特に暗かったわけではない。モデルが読み取っていたのは位置と光で、猫そのものではなかった。それで毛柄はpromptから外した。
Frigate 0.17のカスタム分類#
Frigate 0.17には物体分類機能が内蔵されていて、検出した猫をもう一度分類し、結果をsub labelに書き込める。公式ドキュメントの例が、ちょうど2匹の猫の見分けだ。
classification:
custom:
cats:
enabled: true
name: cats
threshold: 0.8
object_config:
objects: [cat]
classification_type: sub_label作成はFrigateのWeb画面のClassificationページで行う。名前を付け、物体にcat、タイプにsub_labelを選び、クラスにakiとkuroを入れて、クラスごとに学習画像を選ぶ。選ばれなかったものは自動的にnoneに分類される。クラスは1つずつ別々に追加する必要がある。最初「aki / kuro」を1つのクラスとして入力してしまい、画像選択画面のタイトルが「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分。学習ボタンを押したあと、Web画面のスピナーがずっと回り続けることがあるが、状態が更新されていないだけで、止まっているわけではない。
- 名前を付けるルール:毎回の分類の信頼度が
threshold(私は0.8)を超え、しかも少なくとも3回の判定のうち60%が同じ名前のときに、sub labelに書き込まれる。
WARNING
学習したモデルは自動で読み込まれるとは限らない。ログに 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枚。
モデルが正しいかどうやって確かめるか#
モデルは猫ごとに名前を付けてくれるが、それが正しいかどうかは私の目でも判断できない。そこで使ったのが、同時に映っている場面だ。2匹の猫が同時に画面に映っているとき、モデルが付ける名前は必ず違っていなければならない。これは人が判断しなくても決まる正解になる。
実際に計算するときに1つ落とし穴がある。Frigateはときどき同じ猫を見失ってから捕まえ直し、2つの物体にしてしまうので、同時に映っているように見える。だから枠の重なりが大きい(IoUが0.3を超える)ペアは除外し、ほとんど重ならないものだけを本当に2匹いる場面として数えた。
最初は見失い・再捕捉を除くと、本当に同時に映っている場面は2組しか残らず、判断のしようがなかった。9月5日までに本物の同時出現が34組たまったところで、違う名前が付いていたのは13組だけ、38%。2匹に割り当てられた比率はだいたい半々なので、でたらめに当てたときの期待値は50%。モデルはでたらめより悪かった。
次にモデルを自分の学習データで走らせると、正解率は96.2%だった。学習データで96%、見たことのないデータで38%ということは、学習画像を丸暗記していたわけだ。原因は学習データそのものにある。392枚の画像は、実はおよそ30個のイベントから来ていた。私の選び方は、Web画面で追跡物体を1つクリックして、その切り出し画像をまとめて取り込むというものだったので、同じイベントからほぼ同じ画像を5〜20枚続けて入れていた。有効なサンプル数は30前後しかなかった。
学習データにはもう1つ偏りがあった。C210は夜になると赤外線の白黒映像に切り替わり、色の手がかりが丸ごと消える。akiの188枚のうち138枚が白黒(73%)で、kuroは26%だけ。akiはもともと濃い色なので、ラベル付けのとき、暗い画像はみんなakiに振っていた。Recentタブで見たのも同じ傾向で、大半がakiとラベルされ、明るくはっきりした画像だけkuroと認識されていた。ただ、モデルはkuroの白黒画像でも92.5%正しくラベルしていたので、主な原因はやはり丸暗記で、「白黒ならakiと当てる」だけではない。Frigate公式のアドバイスもはっきり書いている。多様性は量より大事で、ほぼ同じ画像を一度に数十枚選ぶのは、モデルを悪くする一番手っ取り早い方法の1つだ、と。
ラベル付けが要らない方法も試した。Frigateはセマンティック検索のために、検出した猫ごとにCLIPのベクトルを保存している。171組の同時出現ペアで、このベクトルが2匹を分けられるか見たところ、結果は56.1%。でたらめよりわずかにましな程度だった。
結論:ボトルネックはカメラ#
リビングのカメラは猫から約3メートル離れていて、猫の顔は画面幅の約6%しかない。同じ柄の2匹では、この距離で見分けられる特徴が少なすぎて、モデルを変えても、数百枚多くラベルを付けても、たぶんあまり改善しない。現実的なのは近距離のカメラ、たとえば給餌エリアに置いて猫から50センチくらいのものだ。
いまはakiとkuroの回数を日記と通知から外し、ページに警告カードを貼っている。給餌器の近接カメラのデータが十分たまったら、同じ同時出現の方法で検証し直すつもりだ。
猫日記#
今回いちばんよく使っているのが日記だ。イベントごとにFrigateが要約を作るので、HA上に独立したページを作り、30分ごとに更新して、夜10時にその日の版をプッシュ通知している。
構成は単純だ。HAが30分ごとに shell_command でPythonスクリプトを実行し、スクリプトはFrigateの /api/review からその日のイベントと要約を取ってきて、1つの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日のキャッシュヘッダーが付いていることで、強制再読み込みでも効かない。最終的に、動画URLの後ろにファイルの更新時刻をバージョン番号として付けて(?v=にmtime)解決した。動画はMarkdownカードの中でvideoタグで直接埋め込むが、幅はwidth="100%"属性で書く必要があり、styleで書くとHAのホワイトリストに剥がされる。ハイライト動画は夜9時50分にならないと生成されず、1日のうち22時間は「今日の動画」が存在しないので、今日の分がなければ最新の1本を表示するようにした。 /local/以下のファイルはログインなしで読めるので、動画を置くフォルダーにはランダムな文字列の名前を付けた。URLをパスワード代わりにしているようなものだ。- プッシュ通知については、家族のiPhoneはHAアプリ経由(理由はステップ7参照)で、日記はパッシブ通知にしてしつこく出ないようにした。異常な音はタイムセンシティブ通知にしている。
日記のほかに、Furboにはない小さな指標もいくつか足した。過去7日の同じ時間帯と比べた活動量、リビングで人が猫と一緒にいた時間、猫を長いあいだ見かけないときのリマインダー。最後のものは最初、固定で4時間にしていたが、ふと気になった。2匹が部屋に行って寝ていたら?カメラはリビングの1台しかない。そこで閾値を過去最長の空白時間の2倍、最低4時間に変え、30分ごとにチェックして、通知文にも「カメラはリビングの1台だけ」と明記した。猫トイレは2つのうち1つしか映らないうえ、モデルは「通りかかった」のか「使った」のかを見分けられないので、「猫トイレに近づいた」回数だけを傾向の参考として表示している。
毎日のハイライト動画#
これはFurboで一番人気の機能をまねて作った。毎晩9時50分に、その日のイベントからクリップを選び、1.6倍速の動画に編集して、日記ページの一番上に置く。Furboの日記は朝7時から夜7時までしか対象にしないが、私はそうしなかった。うちの猫は夜中が一番活発で、深夜2時、3時にそれぞれ三十数個のイベントがあるからだ。
最初の版を見て、何かおかしいと思った。夜に2匹が一緒にソファで、お腹を出して寝ている場面が入っていない。いったいどうやって選んでいるのか。調べると問題が2つあった。1つは、ループが時間の昇順で回って10個選んだら止まるので、いつも深夜から朝9時までしか選ばれないこと。もう1つは、並べ替えに検出の信頼度を使っていて、猫の信頼度はほぼすべて0.98〜0.99の間なので、並べ替えが事実上ランダムだったこと。
修正後、まず複合スコアを試した。2匹が同時に映っていたら加点、クリップの長さでも加点。でも、同時に映っていることが大事なわけではないと思った。ハイライトに入れるべきなのは、かわいい場面や何かが起きている場面で、「このクリップで何をしているか」を本当に知っている情報は、GenAIが書いた要約の1文にしかない。そこで同じqwen3-vl:8bに、自分が書いた要約をもとに、イベントごとに1〜5点を付けさせることにした。遊ぶ、追いかけっこ、毛繕い、伸び、2匹のやりとり、人とのやりとりが5点、じっとしているのが1点。ここにもいくつか罠があった。
- Ollamaの
format: "json"は出力が正しいJSONであることしか保証せず、形式までは保証しない。一度、モデルが勝手に警備報告書のような形式のJSONを吐いたことがある。structured outputsでJSON Schemaを指定して、ようやく安定した。 - 95件を一度に渡して8Bモデルに並べ替えさせると、上位10件しか返さない。20件ずつのバッチにして、1件ずつ採点させるようにした。
スクリプト全体はPythonで、毎日21:50にcronで実行する。中心は3つのAPI呼び出しだ。以下は簡略版。
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 で1本につなぐ。つなぐときは全クリップのエンコードパラメーターを揃える必要があるので、リビングのカメラだけを使い、音声トラックの有無も全部統一する。
1週間動かしても、ハイライトはまだよく変だと感じた。今度は選ぶクリップを間違えているのではなく、クリップそのものの切り方がおかしかった。各クリップを固定で9.6秒切り出していたが、多くのイベントは9.6秒より短く、30クリップのうち9つが切り出し長より短く、0秒のものまであった。なぜ9.6秒固定にしたのか遡ってみると、技術的な理由は何もなく、最初に適当に決めただけだった。イベントの長さに応じて変える(出力2〜10秒)ようにして、合計の長さを180秒に収めた。
最後の問題は重複だ。片方の猫がキャットタワーの丸い猫ハウスでよく寝るので、1日のハイライトに同じ猫ハウスのクリップが何本も入っていた。
最初は要約の文章で重複を判断しようと思ったが、タイトルが同じなのは問題ではなく、大事なのは映像の中身だと思ったので、実際にサムネイルを抜き出して見てみた。結果、文章の類似度はむしろ逆の指標だった。同じ説明でも映像がまったく違うことがあり、違う説明がかえって同じ猫ハウスだったりする。画面全体の類似度で比べるのもだめで、固定された背景に引っ張られてしまう。最終的に使ったのは検出枠の中心位置と面積で、中心の距離が0.1未満、面積の差が2倍以内なら同じ位置とみなし、1つの位置につき最大3クリップまで。クリップ選びは2段階で、最初の70%の時間は純粋にスコアだけで選び、残り30%でふだんあまり選ばれない時間帯を補う。動画が長くなるのは気にしないが、ディスクが足りなくなるのは心配なので、合計の長さは180秒にした。実測で1秒あたり約59KB、1本10MB前後で、7日分保存しても100MBに届かない。
いまは毎日だいたい18〜26クリップ、2分ちょっと、12MB前後の動画ができて、7日間保存している。下は10月12日のハイライトのうちの30秒(もともと音声付きだが、載せるときに外した)。深夜に1匹が出入り口から入ってきて、明け方5時にキャットタワーに跳び乗り、上で大きな欠伸をする。
ついでに書いておくと、この記事を書いているとき、動画で欠伸をしている1フレームを(私のpromptを使わずに)そのままqwen3-vlに聞いてみたら、返ってきたのは「画面の黒猫はキャットタワーの最上段に立っている……口を開けていて、鳴き叫んでいるようだ」(原文は中国語)。虎斑が黒猫に、欠伸が鳴き叫びにされている。前に載せたpromptをあれほど細かく書いたのは、これが理由だ。
リソース使用量#
これはすべて自宅のサーバーで動いている。以下は10月13日に実際に測った数字(このときは給餌器のカメラをもう1台つないでいたので、デコードは2本ある)。
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のハードウェアデコード(カメラ1台ごと) | 約0.3 GB |
| 合計 | 約8.7 GB / 16 GB |
大部分は視覚モデルだ。Ollamaにはモデルをずっと VRAMに置かせている(OLLAMA_KEEP_ALIVE=-1)。読み込み直すたびに待たされるからだ(最初gemma3:4bを使っていたとき、最初の要約に36秒待った)。この数字からすると、8GBのGPUにはこの組み合わせは収まらず、12GB以上あると余裕がある。ハイライト動画の採点も同じ8Bを使っているので、27B版に変えると常駐の8BがVRAMから追い出されることになる。
その他のリソース:
- GPU使用率:ふだんは2〜3%、カード全体のアイドル時は約31W。Frigateの検出は画面に動きがあるときだけ走り、1回約6ms、動きがなければ検出fpsは0。GPUを本当にフルに回すのは要約の生成で、実測では1件約3秒、その間の消費電力は160〜170Wに跳ね上がる。リビングは毎日だいたい160〜240イベントあるので、合計するとGPUがフル稼働するのは1日10分ほど。
- CPUとメモリ:
docker statsで見るとFrigateのコンテナ全体で17%前後(100%がコア1つ分)、メモリは約5GB、コンテナの上限は10GBにしている。 - HDD:リビングのカメラの連続録画は1日約10GB。保存日数はもともと連続録画14日、動きあり30日、イベント60日の予定だったが、ディスクをあまり使いたくないし、一日中狂ったように書き込ませたくもないので、連続録画3日、動きあり14日、イベント30日に縮めた。現在リビングの録画は合計約69GB、ほかにスナップショットとイベントクリップが約6.7GB。ハイライト動画は1日12MB、7日保存なので無視できる。つまり、録画をどれだけさかのぼれるかは完全にディスクの大きさ次第だ。リビングの1日10GBで計算すると、1TBで約3か月の連続録画ができる。Furboが3日か7日かでサブスクの段階を分けているのとは、まったく違う考え方だ。
Furboと比べる#
| 機能 | Furbo(サブスク) | 私のやり方 |
|---|---|---|
| 録画の見返し | 3日または7日、AIが判断したクリップのみ | 連続録画、保存日数はディスク次第(私は連続3日、イベント30日) |
| イベントの要約 | ペットシッターの観察ログ | ローカルのqwen3-vl:8b、繁体字中国語 |
| 毎日のハイライト動画 | あり | あり、LLMが採点してクリップを選ぶ |
| 鳴き声・異常音の通知 | あり | あり、数分前の映像の要約付き |
| ペットごとの見分け | あり(マルチペット識別)、ただしレビューに誤認の不満が多い | できず、同時出現での検証38% |
| おやつ投げ | あり | なし |
| 費用 | カメラNT$1,699から(契約あり)、スタンダードプラン初年度NT$1,840 | 0、手持ちのカメラ・GPU・HDDを使用 |
実は38%と出る前から、カメラ付きの自動給餌器を探していた。もともとはリビングのカメラから見えない床の角度を補うためで、ついでに自動で餌やりもできればと思っていた。猫の見分けに失敗してから、それにもう1つ役目が加わった。猫がごはんを食べているときはカメラから数十センチしか離れていないので、まさに見分けに必要な近接撮影になる。その後買ったものはいまFrigateにもつないである。どうつないだか、見分けの精度は上がったのかは、別の記事に書くつもりだ。
- Furbo台湾:ペットシッタープランの比較furbo.com/tw
- Furbo台湾:キャットカメラ360度版(価格、FAQ)furbo.com/tw
- Furboヘルプセンター:Smart Alerts(緊急アラートの閾値)help.furbo.com
- Furboヘルプセンター:Seizure Alerthelp.furbo.com
- Furboヘルプセンター:Pet ID(マルチペット識別)help.furbo.com
- App Store米国:Furboアプリのレビュー(Pet IDの誤認への不満)apps.apple.com
- App Store台湾:Furboアプリのレビューapps.apple.com
- 數位時代:Furboのサブスク収入がハードウェアを上回ったというインタビュー(2026.10.12)bnext.com.tw
- Frigate:公式ドキュメントdocs.frigate.video
- Frigate:Home Assistant統合docs.frigate.video
- HACS:インストール方法hacs.xyz
- FrigateのHome Assistant統合(HACS)GitHub
- Advanced Camera CardGitHub
- Frigate:顔認識とカスタム分類の学習のコツ(公式Tips)GitHub Discussions
- ntfy:iOSのリアルタイム通知にはupstreamが必要docs.ntfy.sh
- Tapo:RTSPでNVRからカメラ映像を見るtapo.com
- Siamese Networks for Cat Re-Identification(arXiv 2501.02112)arxiv.org
- The Comparison of Individual Cat Recognition Using Neural Networks(arXiv 2410.02305)arxiv.org
- Frigate:物体分類(Object Classification)docs.frigate.video
- Ollama:Structured outputsollama.com
まだコメントがありません
✨ 最初のコメントを残しませんか