Skip to content

Repository files navigation

RoomGuard

Самодельная охрана комнаты: камера, распознавание лица, уведомление в Telegram.

Движение (OpenCV) → человек (YOLOv8 на GPU) → лицо сверяется с эталоном → если это не хозяин: снимок, клип и уведомление в Telegram.

Писалось под себя, но без привязки к конкретному железу: источником годится USB-камера, телефон с IP Webcam или любой MJPEG/RTSP-поток.

Большая часть этого README — не описание кода, а замеры и объяснения, почему сделано именно так. Пороги распознавания, длины окон поблажек, число потоков OpenCV — всё это выставлено по измерениям, и в тексте написано по каким. Если будешь крутить параметры — начни с этих разделов, иначе легко «починить» то, что сделано намеренно.

Структура

├── config.py         # ВСЕ настройки тут, с обоснованием каждой
├── detector.py       # движение (OpenCV) + человек (YOLO) + лицо (face_recognition)
├── main.py           # главный цикл, запись клипа, ротация
├── notify.py         # отправка в Telegram
├── preview.py        # веб-страница «что видит детектор и почему так решил»
├── capture_known.py  # снять эталоны своего лица прямо с камеры
├── selftest.py       # самопроверка всего пайплайна БЕЗ камеры
├── test_opt.py       # тесты логики экономии и оптимизаций
├── roomguard.service # systemd-юнит
└── raspberry-pi/     # unit для малины, которая держит камеру

В репозитории намеренно нет и не должно быть: known/ (фото лица), clips/ и snapshots/ (записи комнаты). Это личные данные, они в .gitignore.

Установка

Нужны Python 3.12+, ffmpeg, и компилятор с cmake для dlib:

sudo apt install ffmpeg build-essential cmake
python3 -m venv venv
./venv/bin/pip install -r requirements.txt

Для YOLO на GPU поставь torch с CUDA под свой драйвер (колесо с pytorch.org), иначе всё поедет по процессору. Веса yolov8n.pt ultralytics скачает сам при первом запуске.

Токен бота и chat id — только через окружение, в коде их нет:

export TG_BOT_TOKEN="123456789:AA..."   # даёт @BotFather
export TG_CHAT_ID="123456789"           # скажет @userinfobot

Проверка, что всё живо (камера не нужна)

./venv/bin/python selftest.py

Синтезирует видео с идущим человеком и гоняет по нему весь боевой путь: движение → YOLO → лицо → клип (H.264) → Telegram. Печатает OK/FAIL по каждому шагу. С --telegram дополнительно реально шлёт сообщение в чат.

Перед первым запуском

1. Положи своё фото

roomguard/known/me.jpg

Проще снять их прямо с камеры:

./venv/bin/python capture_known.py 60 8         # 60 секунд, до 8 эталонов
./venv/bin/python capture_known.py 45 8 --keep  # ДОПИСАТЬ к уже снятым

Во время съёмки меняй позу: прямо, вполоборота влево и вправо, откинься назад, наклонись к столу, подопри голову рукой, посмотри вниз. Эталоны берутся не чаще раза в 4 секунды именно для этого.

Почему это важно, а не придирка: первый набор эталонов снялся за 5 секунд из одной позы, и стоило подпереть голову рукой — дистанция ушла на 0.639, мимо порога, ложная тревога. Узкий набор эталонов не лечится подкруткой FACE_TOLERANCE: 0.64 промахивается и мимо 0.5, и мимо 0.6. Лечится только разнообразием.

Скрипт в конце печатает разброс между эталонами и ругается в обе стороны: меньше 0.25 — это одна поза, тебя не узнают; больше FACE_TOLERANCE — похоже, в кадр попал кто-то ещё.

Пока папка пуста, система срабатывает на ЛЮБОГО человека.

2. Укажи камеру

В config.pyCAMERA_SOURCE, либо аргументом при запуске, либо переменной ROOMGUARD_SOURCE:

  • USB: "/dev/video0" или "0" — проверить: v4l2-ctl --list-devices
  • Телефон как IP-камера (приложение IP Webcam): "http://<адрес-телефона>:8080/video"
  • RTSP: "rtsp://user:pass@IP:554/stream"
./venv/bin/python main.py http://<адрес-камеры>:8080/video

Сетевой источник проверен и работает — MJPEG по HTTP OpenCV читает штатно.

Схема: камера → малина → ПК

экшн-камера ──USB──> Raspberry Pi 3        ──Ethernet──>  рабочая машина
                     ustreamer, MJPEG                    RoomGuard
                     сервис roomguard-cam                YOLO на GPU,
                     http://<малина>:8080/stream         лица, Telegram

Готовый unit для малины — в raspberry-pi/.

Малина нужна не для вычислений — она только отдаёт байты как есть, не декодируя. Смысл в другом: камера держится в режиме PC Camera, только пока поток кто-то читает, а малина включена всегда и поток не отпускает. При камере, воткнутой прямо в ПК, она уходила в Mass Storage каждый раз, когда RoomGuard останавливался.

Трафик ~1 МБ/с (8–10 Мбит/с). Проверено: поток читается на 30.1 fps без сбоев и открывается мгновенно против ~20с у локального USB.

Управление стримером на малине:

systemctl status roomguard-cam
journalctl -u roomguard-cam -f
curl -s http://<малина>:8080/state    # online, captured_fps

Историческая справка: почему выпилено AI-описание кадра

Одна из версий отправляла кадр в Gemini и добавляла к тревоге описание словами. Толку от описания не оказалось, и код удалён. Но выяснение лимитов стоило нескольких часов, поэтому выводы пусть останутся — они неочевидные и в документации Google прямо не написаны:

  • Бесплатная квота считается на проект и на модель, и упирается в число запросов, а не токенов. Текст ошибки 429 при этом называет токенную квоту и сбивает с толку: лимит токенов 250 000/мин, а один кадр стоит ~1100 — и это не зависит от разрешения (1280x720 и 512x288 дают ровно 1101 токен, проверено countTokens). В токены упереться невозможно.
  • Обычные Flash дают 20 запросов в сутки, Lite — 500. Для камеры это разница между «работает» и «не работает», а качество для задачи «кто в кадре и что делает» неотличимо.
  • Псевдонимы использовать нельзя. gemini-flash-latest выглядит разумным выбором, но резолвится в модель с лимитом 20/сутки и может молча переехать ещё куда-нибудь. Только конкретные имена версий.
  • Второй API-ключ ёмкости не добавляет. Проверено экспериментом: новый ключ упёрся в 429, и старый по той же модели сразу же тоже отдал 429. Ведро общее на проект, а не на ключ.
  • У обычных Flash включён thinking, и «мысли» тратят тот же лимит вывода: с maxOutputTokens=200 ответ обрезался до двух слов.

Если будешь возвращать что-то подобное — держи цепочку моделей с разными ведрами квоты и роняй описание, а не тревогу. Уведомление должно уходить с фото и видео независимо от того, ответила ли языковая модель.

Камера Aceline 4K — как она себя ведёт

USB-ID 1f3a:100e в режиме камеры и 1f3a:1002 в режиме флешки. Единственный формат: MJPG 1280x720@30. «4K» — это запись на карту памяти, по USB 2.0 столько не пролезает.

Главное свойство: камера держится в режиме PC Camera, только пока поток кто-то читает. Замерено четыре раза подряд: если никто не открыл /dev/video0, ровно через ~28 секунд камера сама отваливается и возвращается в Mass Storage. Как только RoomGuard начинает читать кадры — держится сколько угодно (проверено 90с подряд, 30.0 fps, ноль сбоев).

Отсюда два следствия:

  1. main.py опрашивает источник раз в секунду — это не косметика, а попадание в окно.
  2. Останавливать RoomGuard надолго нельзя: камера уйдёт в Mass Storage, и вернуть её можно только кнопкой на самой камере. Поэтому Restart=always в сервисе.

Порядок включения:

  1. Воткнуть камеру в USB (лучше напрямую в материнку, не в хаб).
  2. На экранчике камеры выбрать PC Camera, а не Mass Storage.
  3. /dev/video0 появится через 20–25 секунд — драйвер это время бьётся в таймауты -110 на запросах контролов. Это нормально: uvcvideo просто отключает Brightness и Auto Exposure и работает дальше. Не выдёргивать раньше.

Автоподхват: если RoomGuard уже запущен, он сам дождётся камеры и заберёт её в первую же секунду появления.

Запуск

./venv/bin/python main.py

Ctrl+C — выход.

Как сервис (автозапуск)

# в roomguard.service поправить User, WorkingDirectory, ExecStart и токен бота
sudo cp roomguard.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now roomguard
journalctl -u roomguard -f

Посмотреть, что видит детектор

http://<адрес-машины>:8081/

Страница показывает кадр с рамками и — главное — почему принято решение: текущая причина, сколько секунд назад видели хозяина, сколько человек в кадре, площадь движения, остаток паузы после тревоги, счётчик тревог.

Рамки на видео подписаны латиницей (cv2.putText умеет только шрифты Hershey, кириллицы в них нет):

Рамка Значит
зелёная OWNER лицо совпало с эталоном
красная STRANGER лицо найдено и оно чужое
оранжевая NO FACE человек есть, лица не видно

Кадры кодируются в JPEG, только пока страница открыта — вхолостую просмотр не стоит ничего. Отключается через PREVIEW_ENABLED = False, а PREVIEW_HOST можно сузить до 127.0.0.1, если не нужен доступ с других машин.

Ротация записей

После каждой записи клипа отрабатывают два правила подряд (main.rotate_media):

  1. По возрасту — файлы старше KEEP_DAYS (14) удаляются.
  2. По объёму — если clips + snapshots весят больше MAX_TOTAL_GB (5), удаляются самые старые, пока не уложится.

Любое из правил отключается нулём. Клип на 12 секунд весит ~2МБ, так что без уборки диск утекает незаметно.

Про длину клипа и лимит Telegram. CLIP_DURATION по умолчанию 120 секунд. OpenCV пишет промежуточный файл кодеком mp4v со скоростью ~0.78 МБ/с (замерено на 1280x720@30), то есть около 94 МБ за две минуты; в итоговый H.264 это жмётся в разы, но точная цифра зависит от того, сколько в кадре движения. Bot API не принимает файлы больше ~50 МБ, и notify.py не пытается: если клип перевалил 45 МБ, вместо видео уходит сообщение с путём к файлу на диске. Если упрёшься в это — поднимай сжатие (-crf 28 в transcode_h264), а не режь длину: две минуты обычно и есть всё событие.

Когда система решает, что это чужой

Наивная логика «человек есть, лицо не совпало с эталоном → чужой» на практике не работает: YOLO видит человека, а face_recognition не находит лица, стоит закрыть его рукой, отвернуться или встать спиной. На живом кадре это давало тревогу на хозяина, который просто почесал нос.

Поэтому улики делятся по силе:

Что видим Решение
лицо совпало с эталоном не тревога, запоминаем время
лицо есть, но чужое, хозяина видели меньше UNKNOWN_GRACE_SEC назад не тревога
лицо есть, но чужое, хозяина давно не было тревога после UNKNOWN_CONFIRM_SEC
человек есть, лица не видно, хозяина видели меньше OWNER_GRACE_SEC назад не тревога
человек есть, лица не видно, хозяина давно не было тревога после подтверждения
хозяина узнали меньше IDLE_SKIP_SEC назад не смотрим вовсе, экономим — см. ниже

Два окна разной длины, потому что улики разной силы:

  • OWNER_GRACE_SEC = 90 — человек без различимого лица. Улика слабая (отвернулся, стоит спиной), окно длинное.
  • UNKNOWN_GRACE_SEC = 15 — лицо распозналось и оно чужое. Улика сильная, окно короткое. Нужно оно вот почему: замер показал, что тот же самый человек, подперевший голову рукой, даёт дистанцию 0.59–0.64 — окклюзия рта и подбородка уводит кодировку лица дальше любого разумного порога. Между такими позами хозяин опознаётся правильно и таймер обновляется, а настоящий чужак не совпадёт с эталоном ни разу.

Проверено, что окно именно истекает, а не прикрывает чужого навсегда: чужой в кадре через 0 и 10 секунд после хозяина — 0 тревог; через 16 и 60 секунд — тревога уходит.

  • UNKNOWN_CONFIRM_SEC = 1.5 — сколько секунд подряд человек должен выглядеть чужим. Считалось в кадрах, и это была ошибка: при 31 fps с анализом каждого второго «4 кадра» — это 0.27с, защита ни от чего. Настоящий чужак останется в комнате и через полторы секунды, а единичные промахи узнавания длятся доли секунды и отсекаются.
  • FACE_MIN_PX = 0, то есть отбраковка ВЫКЛЮЧЕНА, и это осознанно. Раньше тут стояло 60: казалось, что человек в дверном проёме опознан как чужой ошибочно, раз лицо далеко и в тени. Разбор показал обратное — опознан он был ВЕРНО, это действительно был не хозяин, а лицо в том кадре имело 36×36 пикселей. С фильтром в 60 его бы отбросили, увели под 90-секундную поблажку «лица не видно», и тревоги не случилось бы вообще. Дверной проём — ровно то место, откуда чужой и заходит, и лицо там всегда мелкое: отбраковка ослепляет систему именно там, где она нужнее всего. Не включать не подумав.

При старте детектор считает, что хозяина «только что видели». Без этого каждый перезапуск сервиса давал ложную тревогу на первой же секунде — поблажка не действовала, потому что хозяина не видели ни разу.

Сколько это стоит процессору

Замеры на машине с 20 ядрами и RTX 4070, цифры — CPU-время на один кадр:

Стадия было стало
поиск движения 56.1 мс 1.2 мс
YOLO (человек в кадре) 2.7 мс 2.7 мс
поиск лица в кропе человека 128 мс ~40 мс
декодирование кадра с камеры 18.5 мс 8.8 мс
процесс целиком, хозяин в комнате 400–1600% ядра 37–46% ядра

Три независимые причины, по убыванию вклада:

1. Потоки OpenCV. По умолчанию OpenCV раскидывает каждую мелочь на все ядра, и на двадцатиядерной машине это дороже самой работы: поиск движения на кадре 1280x720 занимал 4.7 мс времени и 56 мс CPU — почти всё уходило на разгон и ожидание пула, а top показывал 83 потока. Тот же код в один поток: 6.3 мс времени и 6.3 мс CPU. Полторы миллисекунды задержки в обмен на девять десятых сожжённого процессора, при бюджете 66 мс на кадр. Ограничение стоит в начале main.py до импорта cv2 — после импорта переменные окружения уже не действуют, пулы созданы. Переопределяется ROOMGUARD_CV_THREADS.

2. MOTION_SCALE = 4 — движение ищется на кадре 320x180 вместо 1280x720 (1.2 мс против 6.3 мс). MOTION_MIN_AREA перенастраивать не нужно: площадь пересчитывается назад в пиксели исходного кадра. Человек занимает проценты кадра, на такой сетке он всё ещё сотни пикселей.

3. FACE_CROP_MAX_H = 400 — лицо ИЩЕТСЯ на уменьшенном кропе. HOG с upsample=1 на кропе 720x640 — 128 мс, на его половине — 33 мс, лицо найдено в обоих. Уменьшается только высокий кроп, а высокий кроп — это человек близко, у него лицо заведомо крупное; человек в дверях даёт кроп низкий и под правило не попадает. Кодируется лицо всегда по полному разрешению: FACE_TOLERANCE выставлен замером до сотых, кормить его мылом нельзя. Проверено на живом потоке — на 5 кадрах дистанция и вердикт совпали с точностью до третьего знака.

Экономия, пока хозяин на месте (IDLE_SKIP_SEC = 45)

Главная трата была не в стадиях, а в том, что система жгла несколько ядер, чтобы тридцать раз в секунду подтвердить уже известное: хозяин в комнате. Тревогу в этом окне всё равно подавляет OWNER_GRACE_SEC.

Поэтому после того, как лицо совпало с эталоном, YOLO и распознавание не запускаются IDLE_SKIP_SEC секунд. Движение при этом ищется по-прежнему на каждом кадре: оно стоит копейки и держит модель фона свежей.

Два свойства, на которых держится безопасность этой экономии:

  • Засыпаем только после фактического узнавания. Стартовая поблажка «хозяина только что видели» выдумана, чтобы не палить тревогу на первой секунде после запуска, и спать по выдуманному поводу нельзя.
  • Если на проверке хозяин не узнался — не досыпаем. Работаем на полной частоте, пока не узнаем его снова или пока не истечёт OWNER_GRACE_SEC и решение не примет обычная логика. Иначе чужой, попавший в кадр аккурат на проверке, не успел бы набрать свои UNKNOWN_CONFIRM_SEC до того, как мы снова заснём.

Проверено на живой системе: цикл «сон 45с → проверка → хозяин узнан → сон» крутится устойчиво, since_me на проверке падает с 44 на 2.

Цена, честно: чужой, вошедший сразу после того, как хозяина видели, останется незамеченным до 45 секунд. Это внутри окна, где тревога и так подавлена поблажкой; пробивало его только ЧУЖОЕ ЛИЦО после UNKNOWN_GRACE_SEC — вот эта реакция и задерживается. Нужна быстрее — ставь 20–30, CPU уже не узкое место. 0 выключает экономию совсем.

В превью это состояние подписано «хозяин на месте, экономим (проверка через N с)», а карточка «людей в кадре» показывает «не смотрим», а не ноль: YOLO не запускался, и ноль был бы неправдой.

Настройка поведения (config.py)

Параметр Смысл
ONLY_UNKNOWN_PERSON True — только чужие. False — любой человек
OWNER_GRACE_SEC сколько секунд человек без видимого лица считается хозяином
UNKNOWN_GRACE_SEC сколько секунд «чужое лицо» считается ошибкой узнавания
UNKNOWN_CONFIRM_SEC сколько секунд подряд подтверждают «чужого»
FACE_MIN_PX лицо мельче — считается нечитаемым. 0 = выключено, и не включать не подумав
KEEP_DAYS / MAX_TOTAL_GB ротация записей по возрасту и объёму
PREVIEW_ENABLED / PREVIEW_PORT живой просмотр работы детектора
MOTION_COOLDOWN пауза после тревоги, секунд (считается от конца записи клипа)
MOTION_THRESHOLD ниже = чувствительнее к движению
MOTION_BG_ALPHA скорость забывания фона. Больше = хуже видит медленное движение
MOTION_SCALE во сколько раз уменьшить кадр для поиска движения. 1 = не уменьшать
IDLE_SKIP_SEC сколько секунд не гонять YOLO и лица после узнавания хозяина. 0 = выключить
FACE_CROP_MAX_H выше какого кропа искать лицо на уменьшенной копии. 0 = никогда
FACE_TOLERANCE ниже = строже распознавание (0.6 стандарт, 0.5 строже)
FACE_UPSAMPLE 2 — видит лица дальше от камеры, но медленнее
CLIP_DURATION / CLIP_PREBUFFER длина записи и предзапись до срабатывания
CLIP_H264 перекодировать клип в H.264 (иначе Telegram не проиграет inline)

Что придёт в Telegram

  1. Текст: время + триггер + длительность записи
  2. Фото с bounding-box (зелёный = ты, красный = чужой)
  3. Короткое видео момента

Грабли, на которые уже наступили

  • face_recognition.face_encodings падает с TypeError на срезе numpy — dlib принимает только C-contiguous массивы. В detector.py кроп обёрнут в np.ascontiguousarray.
  • У gemini-flash-latest включён thinking, и «мысли» тратят тот же лимит, что и ответ. С maxOutputTokens=200 ответ обрезался до «В кадре». Сейчас лимит 800 + thinkingLevel: "low". Модели поколения 2.5 вместо этого хотят thinkingBudget, поэтому describe.py при HTTP 400 повторяет запрос без thinkingConfig.
  • OpenCV пишет mp4 кодеком mp4v — Telegram такое не проигрывает inline. main.py перекодирует клип в H.264 через ffmpeg.
  • Модель фона протухает, пока пишется клип (12с без анализа) — после записи вызывается Detector.reset_background().

About

Самодельная охрана комнаты: движение (OpenCV) → человек (YOLOv8) → сверка лица с эталоном → клип и уведомление в Telegram

Topics

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages