Cat defense: как создать трекинг кота на кухне с помощью YOLO и камеры Raspberry Pi

У нас живёт кот. И он считает, что любая поверхность на кухне есть его территория. И в особенности - кухонный стол, раковина и место для готовки. Всё бы ничего, но дело в том, что с него валится шерсть, а лапы после лотка оставляют на столах следы c сами знаете чем.

Стоит нам сесть кушать, как он уже запрыгивает - и нужно быть вечно на чеку. Посуду надо сразу же мыть. Продукты - сразу же убирать. Поверхности отмывать перед каждым приёмом пищи... Не знаю, может для кого-то это норма, но мы знатно заипались.
![здесь был кот](https://i.vas3k.club/19d1999a5e74b703c9f8120523590b2461c9ae90f4462e2d8d031ca6e951a64c.jpg)
Мы пробовали разные методы: дрессировка с вкусняшками, альтернативный наблюдательный пункт, автокормушка, постоянно убранная еда, начисто вымотые поверхности, отталкиватель запаха...

В какой-то из дней нам пришла идея, что было бы неплохо иметь безобидное средство по отгону кота с территории стола. Что-то типа пуляющее водичкой - эффективно, но безвредно для животного.

Ну и желательно, чтобы оно было еще и автоматизированное - работало, когда нас нет на кухне - для закрепления эффекта. Ну и чтобы при этом водой не обливало нас, людей... Ну и чтобы точно высчитывало координаты кота, и не стреляло мимо... Ну и чтобы там была встроенная камера... Ну и...

В общем, шутка или нет, но в конце концов решили мы взяться за этот вопрос по-серьезному и начали разрабатывать **систему по охране частных территорий от опасного хищника - cat misbehavior preventer**, который меж собой мы скромно называем *"cat defense"*.

**Cat defense system имеет две части:** **Видео систему** по детектингу движения/классификации объектов и **водную турель**, в которую попадают координаты цели.

Первую выполняла я, а вторую взял на себя реализовывать Ден. Этот пост будет только про первую часть, так как водная турель пока еще не готова (Ден только закончил выпиливать детали кастомного насоса на токарном станке, очень упоротый человек :D ).

**Из чего состоит видео-система на данный момент:**
- Камера Raspberry Pi, снимающая кухню под оптимальный углом (libcamera);
- Компьютер Raspberry Pi Zero 2 W, с которого идёт стрим (MediaMTX, RTSP);
- Компьютер Raspberry Pi 5 для получения стрима (ffplay) и работы основной программы (python);
- Основная программа для чтения фреймов (cv2) и прогонке YOLOv8n (ultalytics) для детектинга движения и классификации объектов при использовании Region of Interesr (ROI);
- Fine-tuned версия модели на собственных данных (вручную залейбленных в Label Studio) для повышения точности распознавания кота vs. человеческих волос (код в Google Colab для использования GPU) - процедура достойная, но места в этой статье мало, поэтому будет отдельный пост;
- Buzzer для сигнализации при появлении кота в области интересов + HTTP server (Flask) для удаленного запуска кода сигнализации на Raspberry Pi Zero 2 W;
- Ansible для автоматизации задач и Systemd для работы в фоновом режиме.

Основной текст статьи - это подробное описание того, с какими задачами и проблемами пришлось столкнуться на пути реализации идеи. Возможно, пост будет полезен для тех, кто имеет схожие интересы и потребности. Не судите сторого, так как проект, всё таки, домашний. Комментарии и предложения жду! :)

### Первичный прогон YOLO на маке
Самое логичное, с чего можно начать - это тестирование потенциала модели на том, что под рукой. Беру мак и его встроенную камеру. Качаю ultalytics и импортирую YOLO. Было принято решение использовать YOLOv8n, так как модель легкая и быстрая - то, что надо на нашем скромном частном производстве :D

> **YOLO (You Only Look Once)** - это семейство моделей Computer Vision для обнаружения объектов на видео в реальном времени. Каждый объект на обучающем изображении размечается двумя основными способами:
> - **Label** - это класс объекта (например, "cat", "person", "cap"), *что* именно изображено;
> - **Bounding box** - это прямоугольник, который максимально плотно обводит объект. Он задаётся координатами (x_center, y_center, width, height), отвечает на вопрос *где* находится объект.

Капчурю видео с помощью cv2, делаю луп по чтению фреймов с видео потока и прогонке модели на них. На каждый фрейм модель выдает labels и bounding boxes. Рисуем их на копии фрейма и уже этот финальный фрейм показываем пользователю. Получается непрерывное видео + результаты модели в реальном времени.

Убеждаюсь, что всё работает и идея в принципе *реализуемая*. И сразу же перехожу к проблеме, которую можно решить уже здесь и сейчас...
![модель 💅 на вебке](https://i.vas3k.club/5098a00633f4473f188e83f8cb39cbb2887ccf0895ffa4d9465c55ed08c889cc.jpg)
### Добавляем Region Of Interest (ROI)
Камера затрагивает одновременно все области на кухне: и пол, и диван, и подоконник. YOLO прогоняется по всему, что видит камера, без исключений.

Но всё таки я не хочу превращать всю кухню в "красную зону" для кота. Ведь он может просто гулять по полу, лежать на диване, или запрыгивать на подоконник - с этим притензий к нему нет. Мои таргетные зоны это только столы и раковина - значит, система распознавания должна работать только в этих областях.

Но как дать модели инструкцию "туда не смотри, сюда смотри"?

> **Решение - добавить Region Of Interest (ROI)** - обозначение только таргетной для модели зоны на фрейме, где ей "можно" обрабатывать изображение - всё остальное должно игнорироваться.

Моя реализация "на коленке", без фенси манипуляций - это задать координаты ROI, просто рисуя на фрейме прямоугольник (четыре значения углов: верхний левый, верхний правый, нижний левый, нижний правый).

Минус такого подхода в том, что значения абсолютны и не гибки - сначала нужно поднатаскаться, чтобы выделить интересующую область, а потом нельзя двигать камеру, так как рамка прямоугольника накладывается тупо сверху на изображение и область может "плавать".

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

Задаем координаты ROI, кропаем копию фрейма до этих координат, прогоняем модель на кропнутом изображении, рисуем результаты модели на нем же, вставляем изображение обратно в полный фрейм на то же место, рисуем на полном фрейме границы ROI, показываем финальный полный фрейм пользователю.

Готово! Теперь у вас есть рабочая версия, которая детектит кота только на столе.
![тест ROI](https://i.vas3k.club/9d8042acaa9a1bdeb1b05ad2d9cf2053c5258e9ddcf2a6c479934c873d667794.jpg)
### Камера Raspberry Pi: стрим на мак
Конечно же, YOLO на вебке выглядит *уже впечатляюще*, и является классным стартом для многих проектов. Но моя цель - снимать всю кухню, а не в итоге себя, работающей за ноутом.

Нужна отдельная камера и устройство для передачи сигнала. Поэтому заказываем камеру Raspberry Pi, а вместе с ней и Raspberry Pi Zero 2 W. Теперь можно работать над полноценным стримом!

**Первые шаги**
Физически коннекчу камеру к компьютеру. Нахожу залежавшуюся SD карту и устанавливаю Raspberry Pi OS 64-bit Lite. ssh через мак и проверяю доступ к камере через дефолтный софт libcamera.

Отлично, камера есть! И она уже что-то снимает... Но проверить это "что-то" неможно, так как у Pi Zero нет экрана. Жутко интересно уже сейчас, какое там качество и разрешение, цветопередача, да и не бракованная ли камера вообще. Но нужен еще один шаг.

**Стрим**
> Чтобы стримить видео, нужен **стриминговый сервер**, который через сеть отправляет данные по определенному протоколу. Доступ к этому стриму может получить любое устройство, подключенное к внутренней сети и имеющее, например, ffplay плеер.

Для осуществления плана выбираю сервер MediaMTX. Он поддерживает много протоколов, но нам интересен RTSP (Real-Time Streaming Protocol). Устанавливаю сервер на пишку. Внутри имеем саму программу и .yml файл для конфигурации. Через него создаем стрим, нужный протокол, задаем камеру, параметры изображения, FPS (Frames Per Second) и другие параметры передачи сигнала.

Запускаем сервер на пишке, а на маке подключаемся к нему с помощью ffmpeg с уточнением протокола и других деталей.

Вот и первое видео! Ощущается, как наконец-то получить сигнал с Луны ...Изображение как сквозь запотевшее стекло, ничего не разобрать и суровая задержка... Лезу в конфигурацию MediaMTX, отлаживаю weight and hight, FPS, bitrate, а также параметры буфера в ffplay...

Сработало. Получаем чистое изображение без задержек. Камера отличная, качество приятно удивило, цветопередача оптимальная.

**YOLO на стриме**
Теперь дело за малым - попробовать на этом модель! Тут уже минимум работы, так как модели всё равно, откуда брать фреймы. Беру свой изначальный код и просто меняю способ получения видео с вебки на стрим. Запускаем и получаем результаты модели на камере.
Готово! Всё супер?
![People who don’t party on weekends… what do you do instead??](https://i.vas3k.club/748a9ca97e1e91a9c373182765466e793cfda688f5f1b4c6c2b0c8747c630eef.jpg)

### Troubleshooting: задержка стрима, но где...
... Всё супер только первую секунду. А дальше начинается задержка. Задержка то увеличивается, то уменьшается, и иногда может достигать до 2 минут. Причем диагностика показала, что задержка возникает и на чистом стриме без YOLO. А сервер выдает предупреждение "discarding [N] frames".

Тут причина может быть практически в любом месте.
> Если коротко, то стрим - это отправление фреймов по сети: с камеры посылается бесконечное количество фреймов один за одним, а принимающая программа берет приходящие фреймы и использует в своих целях. Но если подробно, то цепочка выглядит примерно так: **camera → encode → buffer → send → receive → buffer → decode → display**.

Тут и сами пакеты данных могут приходить нерегулярно и с вариативностью в интервалах (jitter). Есть и буфер, в котором хранятся пришедшие фреймы перед декодингом. И если он не оптимальный, то может возникать задержка или глитчи.

Есть еще кабели, которые могут быть причиной плохого сигнала. Есть параметры принимающего / отправляющего устройства. Есть роутер. Есть конфигурации в стриминговом сервере. Есть сам протокол. Есть приоритеты процессов в плеере... Ну, вы поняли.

Иду разруливать...

Признаться, эта часть траблшутинга оказалась для меня самой полезной, так как потребовала системного взгляда. Начинаю диагностику и разборку причин, вот некоторые из них:

- **Слишком высокое значение FPS**. Если посылается слишком много фреймов в секунду, то они могут оставаться у принимащего устройства в буфере и тем самым вызывать задержку.
Но у меня и так FPS=30 (стандарт для большинства стримингов), а ffplay даже задает параметры nobuffer и low_delay. Так что что-то другое заставляет расти задержку. Rejected.

- **WiFI interference.** Второе самое простое, с чего начать. Качество сигнала может зависеть от дистанции. А значит, задержку может вносить моё перемещение с маком по квартире.
Смотрим уровень сигнала. Сигнал стабильный (до 280 KiB/s) вне зависимости от расстояния. Rejected.

- **Перегруз и throttling** Может быть, пишка слишком слабая.
Открываю btop и смотрю параметры CPU, RAM, throttling до и после запуска кода. Всё в норме. Rejected.

- **Проблема с принимащим устойством**. Для диагностики открываем стрим с двух разных ноутов. "Плавающая" задержка в какой-то момент появляется и на втором ноуте. Rejected.

Ни одна из причин не оказалась достойной. И хотя источник задержки я так и не нашла, зато нашла более прямой и общий способ избежать такие проблемы в будущем. Да и признаться, надо было сделать это сразу... Но мы ведь здесь для того, чтобы учиться на своих ошибках, верно?
![разбор внутрянки](https://i.vas3k.club/4a892f8d4403861c88e8f5db60c787f9583e626eaa4c7d1b02c52c6837e33263.jpg)
### Decoupling: отделяем reading от processing
Если программа делает тяжелую работу (запустить YOLO, нарисовать результаты, задисплеить финальный фрейм...), то это занимает много времени, а значит, программа пропускает новоприходящие фреймы.

Когда с предыдущим фреймом покончено, "тяжелая" программа берет *следующий по порядку* фрейм из буфера. Но фрейм этот уже *устаревший*, в то время как до *актуальных* очередь еще далека.

От того задержка только продолжает расти со временем.

Для того, чтобы YOLO всегда брала самый последний свежий фрейм, можно и нужно сделать decoupling.

> **Decoupling** есть разделение обязанностей между частями программы. Например, reading и processing: пусть одна часть (producer) принимает и читает фреймы, а другая (consumer) - использует их по нужному назначению. В том, чтобы работали они одновременно, помогает **thread** - отдельный поток исполнения процесса.

Создаем reader thread, который постоянно принимает данные со стрима, и main thread, с основным кодом по YOLO. Задаем shared variable - это фрейм: первый сюда записывает, второй отсюда берет.

Не забываем добавить lock, чтобы только один thread имел доступ к переменной в моменте. И копию фрейма для main thread, чтобы избежать оверлапа. Так мы предохраняем себя от проблемного race condition.

Запускаем код с моделью и видим чудесное улучшение! Картинка плавная, ничего не тормозит, результаты модели показываются, информация всегда актуальная. Вуа-ля!
![мистер кот, вы обнаружены](https://i.vas3k.club/988b44c24a4818a893f7119b3eefc6ef0f40cc230eeb0f06b334c15e84fa8c39.jpg)
### Добавляем сигнализацию: кот на столе!
Камера закреплена, стрим работает, кот сидит, модель детектит. Но просто смотреть на это - бесмысленно и скучно. Настало время извлекать какие-то реальные бенефиты из видео системы.

В изначальном плане это координаты кота, отправляемые в водную турель. Но пока турель не готова, можно имплементировать что-нибудь более простое. Например, сигнализацию, срабатывающую на таргетное условие - кот на столе.

**Buzzer**
Для начала находим у мужчины в закромах позабытый piezzo buzzer и выпрашиваем провода к нему. Вручную подключаем к Raspberry Pi, предварительно найдя верную документацию по пинам.

Так как buzzer оказался активным, то всё, что нам нужно для его активации, это послать сигнал на соответствующий GPIO пин. Пишу тестовый скрипт на Pi с обращением к lgpio, задаю номер нужного пина, корректирую частоту и продолжительность звука, ну и "выключаю" пин после.

Звук есть, пищит мерзко. То, что надо!

**HTTP server Flask**
Напоминаю, что основной код с YOLO всё еще запускается с ноутбука, в то время как buzzer работает на пишке. Теперь нужно связать их между собой для удаленного запуска сигнализации. Выбор пал на установку **HTTP сервера (Flask)** на Pi.

> HTTP сервер это маленький веб сервер, который "слушает" приходящие на определенный адрес запросы из внутренней сети. Когда запрос поступает, сервер запускает скрипт вызова сигнализации.

Устанавливаю Flask на Raspberry Pi. Там же запускаю скрипт для сервера, в котором задаю адрес роута и инструкцию по запуску скрипта сигналки. С ноутбука шлю запрос на сервер по соответствующему адресу. Бзыыыыынь. Работает!

**Условия в YOLO**
Осталось всё это интегрировать в основной код с YOLO. Прописываю условие: если найдена категория cat, шли запрос на сервер. Немного думаю и добавляю: если найдена категория cat или dog. Потому что YOLO часто классифицирует этих животных одинаково.

Запускаем... И ва-ха-ха! Теперь у кота нет шансов запрыгнуть на стол без палева, так как предупреждающий звук раздается по квартире. И боже, как же часто кот это делает, особенно если нас нет на кухне, кто бы знал.

Какой-то вполне рабочий инструмент уже есть! Обарачиваясь назад - всё же неплохой путь был проделан: достаточно кода написано, модификаций сделано, шагов проделано и проблем решено.

Надо бы всё записать, а то и не повторишь. Но это когда-нибудь потом, а пока, на этой успешной части, я закрываю ноутбук и отправляюсь отдыхать, довольная закрепленным результатом.

Какой же неприятный сюрприз ждал меня на следующий день...
![you the real mvp](https://i.vas3k.club/76b3050eaf5f1c4f0196c2368043bd9ca890db2d087721570e902cffda81344c.jpg)
### Зачем нужна автоматизация
... А на следующий день Raspberry Pi решил сказать "досвидания", и перестал включаться. Точнее, полетела microSD. Ну и вместе с ней и все коды, конфиги и сервера на пишке. Как же это выбесило.

Но еще больше выбесило то, что я полагалась только на свои руки и память, не автоматизируя/дублируя/синхронизируя установки программ и создания файлов на пишке. Ладно хоть на ноуте были комиты в git.

Короче, всё полетело к чертям. А что там было, какие конфиги, какие траблшуты? Хрен его знает, я и не помню уже.

Очевидно, что это проблема классическая: и железяки, и всякие карты могут подвести в любой момент. Так что пришлось обратиться к автоматизации.

**Ansible**
Беру новую microSD и заново устанавливаю Linux. Дальше на пишке никакой работы ручками! Вместо этого оперируем через Ansible, так как Docker или Github Actions не очень подходят, да и это overkill.

> С помощью **Ansible** можно ssh на другое устройство, запускать команды, устанавливать программы, копировать и редачить файлы, запускать сервера. Работает через playbook (файл с инструкцией .yml). Удобно, что если запускаешь playbook второй раз, он имплементит только то, что отличается. Неудобно то, что playbook прогоняется медленно, так что я бы не использовала Ansible для многоэтапной корректировки кода.

На ноут устанавливаю Ansible. Создаю два файла в единой дириктории: .ini (просто адрес пишки и username) и непосредственно playbook. В последний записываю необходимую инструкцию. Тут и базовые программы нужной версии (типа python и Flask), и конфиги MediaMTX, и запуск питоновских скриптов - всё в одном месте.

Далее просто запускаем playbook на ноуте и корректим ошибки, если появятся (да, они появятся).

Иииии готово! Теперь, даже если пишка вновь перестанет работать, процесс не займет дольше, чем запуск одного плэйбука.

**systemd**
Последний штрих - предотвратить крах программ при неисправностях и запустить их в фоновом режиме. Напомню, что пока что и MediaMTX, и HTTP сервер работают только потому, что их вручную запускают в терминале. Ну то есть они крэшатся в момент закрытия терминала или неполадки аппаратуры.

Чтобы это исправить, используем systemd.

> systemd это линуксовский тул для работы на уровне системы по менеджменту программ. Работает через конфигурационный unit file (.service), где нужно прописать что запускать, от какого юзера, и что делать в случае failure.

Создаю unit files для MediaMTX и HTTP сервера. Добавляю инструкцию по их копированию и запуску в Ansible playbook. Запускаю. И всё.
Остался последний шаг.

### Последний шаг: избавляемся от ноутбука
Осталось совершить последний шаг для того, чтобы система работала автономно. Перенесем основной код с ноутбука на Raspberry Pi 5 - стационарный домашний компуктер. Благодаря этому, catduril будет работать автономно 24 на 7 без вмешательства.

На пишке нет экрана, а значит код нужно модифицировать под headless mode. Для этого меняем opencv-python на opencv-python-headless и проводим мелкие модернизации в основном коде по чтению фреймов.

Не забываем про uv venv, и поэтому заранее готовим requirements.txt. Также тут тоже работаем с systemd, так как нужно, чтобы программа перезапускалась при крэшах. Ну и под это всё готовим соответствующий Ansible playbook и .ini.

Запускаем. Проверяем. Исправляем ошибки.

ИИИИиииии...
![Сердце проекта](https://i.vas3k.club/40592afe96edef57aba5eaf9bab7618563a1dd0f9783a05083d182780f4f1765.jpg)
### (Не)финальный результат
Иииитак, что мы имеем под конец всех манипулиций? Маленькое куханное устройство, которое мы повесили на стену для оптимального угла съемки. Система мониторит кота и пищит, когда тот запрыгивает на столы или лезет в раковину.

**Плюсы уже сейчас:**
- Затраты на систему небольшие (камера - 32 евро, пишка - 20 евро), заказ на Amazon, быстрая доставка;
- Камера миниатюрная, но качественная;
- YOLO бесплатная, мало весит и быстро работает, точность удовлетворительная;
- Камера не ночного видения, но это оказалось плюсом, так как ночью нет сигнализации (YOLO не детектит);
- Система позволяет собрать статистику по поведению кота и залогить его активность на кухне;
- Теперь кот не спиздит забытую еду (ну если ты успел добежать).

**Задачи завтрашнего дня:**
- Fine-tuning модели для повышения точности классификации, ибо оказалось, что YOLO принимает кудрявые волосы за кота, а вот жопу кота не распознает вообще. Вполне понимаю оба эти пробела в тренинговых данных. - об этом будет отдельный пост, так как тут не хватило место.
- Извлечение координат объекта - тут возникнут проблемы с 2D изображением, вероятно придется достраивать 3D сцену для повышения точности.
- Крепить ли камеру на турель (тогда будет проще с координатами, но сложнее с ROI), или имплементировать турель отдельно (камера зафиксирована).
- Добавлять ли ночное видение, чтобы турель работала круглосуточно.

Так как проект ongoing, будут еще две статьи: про fine-tuning модели на собственных данных, а также про интеграцию catduril с водной турелью. Так что не прощаемся!

А вообще, уже сейчас классный получился проект, ибо дал пачку скиллов и знаний, от установки линукса до деплоймента модели.

Кстати, пользуясь случаем: я ищу работу в DL/CV и буду рада, если этот пост поможет расширить знакомства в этой области😅👉👈