Самодельная охрана комнаты: камера, распознавание лица, уведомление в 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 дополнительно реально шлёт сообщение в чат.
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 — похоже,
в кадр попал кто-то ещё.
Пока папка пуста, система срабатывает на ЛЮБОГО человека.
В config.py → CAMERA_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Одна из версий отправляла кадр в 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ответ обрезался до двух слов.
Если будешь возвращать что-то подобное — держи цепочку моделей с разными ведрами квоты и роняй описание, а не тревогу. Уведомление должно уходить с фото и видео независимо от того, ответила ли языковая модель.
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, ноль сбоев).
Отсюда два следствия:
main.pyопрашивает источник раз в секунду — это не косметика, а попадание в окно.- Останавливать RoomGuard надолго нельзя: камера уйдёт в Mass Storage, и вернуть её
можно только кнопкой на самой камере. Поэтому
Restart=alwaysв сервисе.
Порядок включения:
- Воткнуть камеру в USB (лучше напрямую в материнку, не в хаб).
- На экранчике камеры выбрать PC Camera, а не Mass Storage.
/dev/video0появится через 20–25 секунд — драйвер это время бьётся в таймауты-110на запросах контролов. Это нормально:uvcvideoпросто отключает Brightness и Auto Exposure и работает дальше. Не выдёргивать раньше.
Автоподхват: если RoomGuard уже запущен, он сам дождётся камеры и заберёт её в первую же секунду появления.
./venv/bin/python main.pyCtrl+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 -fhttp://<адрес-машины>:8081/
Страница показывает кадр с рамками и — главное — почему принято решение: текущая причина, сколько секунд назад видели хозяина, сколько человек в кадре, площадь движения, остаток паузы после тревоги, счётчик тревог.
Рамки на видео подписаны латиницей (cv2.putText умеет только шрифты Hershey,
кириллицы в них нет):
| Рамка | Значит |
|---|---|
зелёная OWNER |
лицо совпало с эталоном |
красная STRANGER |
лицо найдено и оно чужое |
оранжевая NO FACE |
человек есть, лица не видно |
Кадры кодируются в JPEG, только пока страница открыта — вхолостую просмотр
не стоит ничего. Отключается через PREVIEW_ENABLED = False, а PREVIEW_HOST
можно сузить до 127.0.0.1, если не нужен доступ с других машин.
После каждой записи клипа отрабатывают два правила подряд (main.rotate_media):
- По возрасту — файлы старше
KEEP_DAYS(14) удаляются. - По объёму — если
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 кадрах дистанция и вердикт совпали с точностью до третьего знака.
Главная трата была не в стадиях, а в том, что система жгла несколько ядер,
чтобы тридцать раз в секунду подтвердить уже известное: хозяин в комнате.
Тревогу в этом окне всё равно подавляет 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 не запускался, и ноль был бы неправдой.
| Параметр | Смысл |
|---|---|
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) |
- Текст: время + триггер + длительность записи
- Фото с bounding-box (зелёный = ты, красный = чужой)
- Короткое видео момента
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().