🔑 핵심 요약
✦ 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 요약 켜기), 로컬 비전 모델 세 개의 비교, 그리고 prompt로 환각을 약 10%에서 2.5%까지 줄인 방법을 기록했다. 유일하게 실패한 것은 무늬가 같은 두 마리 고등어 태비를 구분하는 일이다. Frigate 내장 커스텀 분류로 학습시킨 뒤 "한 화면에 같이 있으면 이름이 반드시 달라야 한다"는 기준으로 검증했더니 정확도는 38%로, 무작위로 찍는 50%보다도 낮았다. 원인은 학습 이미지가 약 30개 이벤트에서만 나왔기 때문에 모델이 학습 세트를 외워 버렸고, 거실 카메라가 너무 멀리 있다는 점이다. 후반부에서는 일기 페이지와 매일 하이라이트 영상을 만드는 방법(LLM이 요약을 보고 구간마다 점수를 매기게 하고, 감지 박스 위치로 중복을 피하기)을 설명하고, 실제로 측정한 자원 사용량을 붙였다. 전체 구성은 약 8.7GB VRAM, 거실 카메라 녹화는 하루 약 10GB다.
우리 집에는 고등어 태비 두 마리, aki와 kuro가 있다. 고양이를 키우기 시작한 뒤로 Furbo 펫 카메라 광고가 계속 눈앞에 떴다. 원격으로 간식 던지기, AI 펫시터, 매일 자동으로 편집해 주는 고양이 일기, 구토 감지. 자꾸 보다 보니 정말 마음이 흔들려서 한동안은 주문 직전까지 갔다. 그런데 제대로 알아보니 내가 원하는 기능은 거의 다 구독에 묶여 있었고, 녹화도 AI가 반려동물이 있다고 판단한 구간만 저장한다는 걸 알게 됐다.
마침 거실에는 Tapo C210 한 대가 원래부터 켜져 있었다. 다만 SD 카드가 없어서 실시간 화면만 볼 수 있고 다시 보기는 안 된다. 그래서 결국 Furbo는 사지 않고, 대신 Furbo의 기능 목록을 목표로 삼아 Frigate와 Home Assistant로 직접 만들었다. 녹화를 다시 볼 수 있고, 고양이가 움직이면 알림이 오고, 매일 "오늘 고양이가 뭘 했는지"를 자동으로 정리해 주는 것. 2주 정도 만들어 본 결과, 녹화, 알림, 로컬 AI가 쓰는 고양이 일기와 매일 하이라이트 영상은 다 쓸 만하게 됐고, 유일하게 실패한 건 "어느 고양이인지 구분하기"였다. 분류 모델을 학습시켜서 두 마리가 한 화면에 같이 나온 장면으로 검증했더니 정확도가 38%, 무작위로 찍는 50%보다도 낮았다.
이 글은 순서대로 기록한다. 내가 찾아본 Furbo의 단점, 처음부터 구축하는 과정(compose와 config를 다 붙였으니 자기 계정 정보로 바꾸면 바로 쓸 수 있다), 어떤 로컬 비전 모델을 골랐는지, 고양이 구분이 왜 쓸 수 없다는 결론이 났는지, 일기와 하이라이트 영상은 어떻게 만들었는지, 마지막으로 전체를 돌렸을 때 VRAM과 디스크를 얼마나 먹는지.
Furbo를 살 뻔했다가, 알아보고 나서야 알게 된 것#
아래는 2026년 10월에 Furbo 대만 공식 사이트에서 확인한 내용이다.
- 카메라: 고양이 카메라 360도 버전 페이지에는 "최저 NT$1,699"라고 적혀 있지만, 그건 펫시터 구독을 같이 묶었을 때의 가격이다(최소 3개월 약정). 약정 없는 단품은 공식 쇼피 스토어에서 NT$5,399다. 박스에 전원 어댑터가 들어 있지 않아서 NT$299에 따로 사야 하고, Wi-Fi는 2.4GHz만 지원한다.
- 구독 없이 쓸 수 있는 것: 실시간 화면, 양방향 통화, 간식 던지기, 낚싯대 장난감, 야옹 알림. 녹화는 어떤 것도 다시 볼 수 없다.
- 펫시터 구독: 스탠다드 플랜은 연간 결제 시 첫해 NT$1,840(월간 결제 정가는 월 NT$219), 녹화 보관 3일. 프리미엄 플랜은 월 NT$188부터, 보관 7일. 대만의 두 플랜은 보관 일수만 다르고, 고양이 일기, 구토 감지, 다중 반려동물 인식은 양쪽 다 있다. 카메라를 한 대 더 늘리면 대당 매달 NT$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 카메라는 먼저 앱의 카메라 설정 → 고급 설정에서 "카메라 계정"을 하나 만들어야 한다. 이 계정 정보는 Tapo에 로그인하는 계정과 별개다. 만들고 나면 스트림 주소는 다음과 같다.
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). 설치한 다음 컨테이너에서 그래픽카드가 보이는지 먼저 확인한다.
sudo nvidia-ctk runtime configure --runtime=docker
sudo systemctl restart docker
docker run --rm --gpus all ubuntu nvidia-smiOllama는 호스트에 바로 설치하고, 비전 모델을 받는다.
curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen3-vl:8b-instruct
sudo systemctl edit ollamasystemctl edit로 열린 파일에 아래 두 줄을 넣는다. 첫 줄은 컨테이너에서 접속할 수 있게 하고, 둘째 줄은 모델을 VRAM에 상주시킨다(이유는 뒤의 자원 섹션에서).
[Service]
Environment="OLLAMA_HOST=0.0.0.0:11434"
Environment="OLLAMA_KEEP_ALIVE=-1"여기에 함정이 하나 있다. ufw를 켜 두었다면 Frigate 컨테이너가 여전히 Ollama에 접속하지 못한다. Ollama는 호스트의 프로세스라서, 컨테이너에서 들어오는 트래픽이 ufw의 기본 deny에 막힌다. 반대로 MQTT는 컨테이너 안에서 돌고 포트를 Docker가 publish하기 때문에 ufw를 우회한다. 그래서 "MQTT는 되는데 Ollama는 타임아웃"이라는 상황이 생긴다. 해결 방법은 Frigate의 Docker 대역만 허용하는 것이다(대역은 아래 compose에서 고정해 둔다).
sudo ufw allow from 172.28.0.0/24 to any port 11434 proto tcp3단계: MQTT broker 띄우기
나는 Mosquitto를 쓴다. 처음에는 예전에 ESP32용으로 띄워 둔 broker를 빌려 쓰면서 계정만 두 개 추가했다. 그런데 한번은 호스트를 재부팅했더니 그 프로젝트의 다른 서비스는 다 살아났는데 broker만 안 올라왔고, Frigate의 엔티티가 HA에서 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/계정은 두 개 만든다. 하나는 Frigate용, 하나는 HA용이다(첫 번째 명령의 -c는 새 파일을 만들기 때문에 두 번째에는 붙이지 않는다).
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가 자동으로 그래픽카드에서 돈다.
모델은 RF-DETR Nano를 골랐다. 0.17의 NVIDIA용 CUDA Graphs를 지원하기 때문이다. 공식 문서의 내보내기 명령은 아래와 같고, 내가 바꾼 건 CPU 버전 PyTorch를 먼저 설치하는 부분 하나뿐이다. 공식 명령은 CUDA 버전을 받아서 다운로드가 몇 GB나 되는데, ONNX로 내보내는 데는 그래픽카드가 전혀 필요 없다.
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에 쓰고, 그다음 하드디스크로 옮길지 버릴지 결정한다. 이 임시 파일이 컨테이너 파일 시스템에 남아 있으면 24시간 내내 시스템 디스크에 썼다 지웠다 하는 셈이라 메모리에 둔다. 처음에 보관 일수와 함께 조정한 부분인데, 목적은 "미친 듯이 쓰기"를 피하는 것이었다.8971은 Docker가 publish한 포트라서 마찬가지로 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 작성법은 "로컬 비전 모델 고르기" 섹션에 있다.
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 다음에 웹 인터페이스는 https://主機IP:8971이다(자체 서명 인증서라서 브라우저가 경고를 띄운다). 이후 config.yml만 바꿨다면 docker compose up -d는 아무것도 하지 않으니(compose.yml이 그대로라서) docker compose restart frigate를 써야 한다.
admin 초기 비밀번호는 첫 실행 때 log에 딱 한 번만 찍히니 실행하자마자 바로 적어 둘 것:
docker logs frigate 2>&1 | grep -i passwordlog가 로테이션으로 지워지거나(몇 번 재시작하면 그렇게 된다) 컨테이너가 다시 만들어지면(앞에서 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 공식 문서대로 먼저 설치해 두자.
- MQTT 통합부터 추가: 설정 → 기기 및 서비스 → 통합 추가 → MQTT에서 broker 주소와 hass 계정을 입력한다. 나는 처음에 이 단계를 빼먹어서 Frigate 엔티티가 전부 "사용 불가"였고, HTTP API로 가져오는 sensor 몇 개만 값이 있었다. 겉보기엔 Frigate가 고장 난 것 같았다.
- Frigate 통합 설치: HACS에서 Frigate를 검색해 설치하고, HA를 재시작한 뒤 통합을 추가한다. 주소는
http://127.0.0.1:5000. 내 HA는 host 네트워크이고 Frigate와 같은 머신이라서, loopback에 있는 인증 없는 API로 바로 붙는다. 설치가 끝나면 엔티티가 수십 개 생기는데, 예를 들면sensor.keting_cat_count(화면 속 고양이 수),binary_sensor.keting_hiss_sound(하악질 감지) 같은 것들이다. - GenAI 스위치 켜기: Frigate 통합은 GenAI 스위치를 두 개(객체 설명, review 요약) 만드는데, 기본값이 비활성화라서 직접 활성화한 다음 HA를 재시작해야 한다. 또 통합이 제공하는 스위치와 숫자 엔티티(예를 들어
number.keting_contour_area)는 런타임 덮어쓰기라서 config의 값을 덮어쓴다. config를 바꿨는데 효과가 없다면 여기부터 확인하자. - 다시 보기 인터페이스: 마찬가지로 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 앱이 깔려 있었고 그건 애플의 APNs를 타기 때문에, iPhone 알림은 전부 HA 앱으로 바꿨다. 집에 휴대폰 종류가 하나뿐이라면 하나만 고르면 되고, HA 앱이 제일 편하다.
ntfy를 쓴다면 작은 함정이 두 개 있다. 새로 만든 토픽은 휴대폰 앱에서 따로 구독해야 하고, 안 그러면 테스트 알림을 아무리 보내도 반응이 없다. 첨부 파일 크기 상한은 server 설정 파일에서 직접 정하는 값이라 영상이 너무 크면 막힌다.
알림에 "방금 화면" 붙이기
"거실에서 하악질 감지"라고만 쓰면 사실 무슨 일이 있었는지 알 수가 없어서, 알림에 GenAI 요약 한 줄을 붙이게 했다. 문제는 요약이 그 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 專注模式스크립트가 출력하는 건 텍스트 한 줄이다. 예를 들어 이 부분을 쓰면서 실제로 돌려 본 결과는 "15:41 두 고양이가 각각 소파와 캣타워 숨숨집 안에서 쉬고 있고, 주변은 조용하며 이상 활동 없음."이었다(원문은 중국어). 이 줄이 알림 본문의 "방금 화면:" 뒤에 그대로 붙는다.
돌려 보고 나서 손봐야 했던 두 군데#
위의 config에는 이미 두 가지 수정이 들어가 있다. 여기서는 왜 바꿨는지 설명한다.
첫 번째는 motion이 거의 계속 트리거된다는 것이었다. 통계를 내 보니 10초짜리 녹화 조각 2412개 중 2348개가 움직임 있음으로 판정됐는데(97.3%), 실제로 객체가 있었던 건 6.3%뿐이었다. 원인은 두 가지다. 화면 왼쪽 위에 새겨진 타임스탬프가 매초 바뀌면서 정지 화면에서 변하는 픽셀의 80% 이상을 차지했고, 또 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이다.
두 번째는 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)인데도 요약에는 "거실은 완전히 정지 상태"라고 적혀 있었다.
로컬 비전 모델 고르기#
같은 이미지 세트로 모델 세 개를 비교했다. 화면에는 실제로 고등어 태비 두 마리가 있고, 환각 대조군으로 빈 화면 몇 장도 섞었다.
| 모델 | 고양이 수 | 무늬 | 사람/고양이 | 중국어 | 소요 시간 |
|---|---|---|---|---|---|
| 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), 아예 objects.track에서 dog를 빼 버렸다.
지금 쓰고 있는 전체 prompt(activity_context_prompt)
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와 옛 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의 요약은 어떨 때는 "검은 고양이", 어떨 때는 "태비 고양이"라고 써서 이걸로 바로 구분할 수 있을 것처럼 보였다. 그런데 같은 이벤트에서 1초 전에는 "두 마리 다 태비"라고 쓰고, 다음 1초에는 "한 마리는 태비, 한 마리는 검은 고양이"라고 쓴다. 이벤트 33개를 박스 위치와 밝기로 집계해 보니, 검은 고양이라고 불린 6번은 전부 화면 오른쪽 문 근처, 즉 역광 위치에서 나왔고 밝기가 딱히 더 어둡지도 않았다. 모델이 읽은 건 고양이 자체가 아니라 위치와 빛이었다. 그래서 나중에 prompt에서 무늬 이야기를 뺐다.
Frigate 0.17의 커스텀 분류#
Frigate 0.17에는 객체 분류 기능이 내장되어 있어서, 감지된 고양이를 한 번 더 분류하고 그 결과를 sub label에 쓸 수 있다. 공식 문서의 예시가 마침 고양이 두 마리를 구분하는 것이다.
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가 원래 좀 더 진하다 보니, 라벨을 붙일 때 어두운 이미지는 다 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 앱으로 보내고(이유는 7단계 참고), 일기는 수동(passive) 알림으로 설정해서 자꾸 튀어나오지 않게 했다. 이상한 소리는 시간 민감 알림으로 설정했다.
일기 말고도 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 호출 세 번이다. 아래는 단순화한 버전이다.
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개 구간으로 된 2분 남짓, 12MB 안팎의 영상이 나오고, 7일 보관한다. 아래는 10월 12일 하이라이트 중 30초 분량이다(원래는 소리가 있었는데 올리면서 뺐다). 새벽에 고양이 한 마리가 문 쪽에서 걸어 들어오고, 새벽 5시에 캣타워에 뛰어올라 그 위에서 크게 하품을 한다.
덧붙이자면, 이 글을 쓰면서 영상 속 하품하는 프레임을 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초, 그동안 전력이 160170W까지 치솟는다. 거실에서 하루에 이벤트가 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% |
| 간식 던지기 | 있음 | 없음 |
| 비용 | 카메라 NT$1,699부터(약정), 스탠다드 플랜 첫해 NT$1,840 | 0, 기존 카메라와 그래픽카드와 디스크 활용 |
사실 38%라는 결과가 나오기 전부터 나는 카메라 달린 자동 급식기를 찾고 있었다. 원래는 거실 카메라가 못 찍는 바닥 각도를 보완하고, 겸사겸사 자동 급식도 하려던 것이었다. 고양이 구분에 실패하고 나니 임무가 하나 더 생겼다. 고양이가 밥을 먹을 때는 카메라와 겨우 수십 센티미터 거리라서, 딱 고양이 구분에 필요한 근접 촬영이 된다. 나중에 산 그 급식기도 지금은 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
아직 댓글이 없어요
✨ 첫 댓글을 남겨보세요