STM32 з нуля без HAL. Частина 18: OpenCV детекція руху на Pi. Частина 18

«Цього тижня мав робити простий детектор руху. Замість цього довелося будувати архітектуру комп’ютерного зору.»

Минулого тижня я закінчив інтеграцію Raspberry Pi в Автобот. Здавалося, що цього разу все буде досить просто: підключити CSI-камеру, написати кілька десятків рядків коду на OpenCV, знайти рух у кадрі й записати короткий відеофрагмент.

На практиці все виявилося значно цікавішим. Raspberry Pi OS переперейменувала майже всі утиліти для роботи з камерою. NoIR-камера бачить світ зовсім не так, як людське око. OpenCV кілька разів змусив мене шукати помилки там, де їх не було. А найцікавіший баг тижня взагалі виявився не в алгоритмі детекції, а в тому, що фонова модель переставала оновлюватися саме тоді, коли починався запис відео.

Але найбільшим відкриттям тижня став навіть не MOG2, а поступова міграція в окремий модуль комп’ютерного зору.

MOG2 (Mixture of Gaussians 2) — популярний алгоритм комп’ютерного зору для виділення рухомих об’єктів через віднімання фону (background subtraction). Він моделює кожен піксель як суміш гауссівських розподілів, адаптується до зміни освітлення й визначає ті області кадру, де відбувся рух.

Сьогодні він знаходить рух. Через кілька тижнів його місце займе YOLO. Потім, можливо, власна нейромережа. Але камера, кільцевий буфер, запис кліпів, система подій і взаємодія з іншими модулями залишаться майже незмінними.

Саме ця думка й визначила архітектуру всього тижня.

◆ Що робимо цього разу

Будуємо автономний модуль комп’ютерного зору для Raspberry Pi, який:

  • працює повністю локально;
  • отримує відео з CSI-камери;
  • використовує MOG2 для детекції руху;
  • зберігає кілька секунд до події та після неї;
  • не залежить від мережі;
  • уже зараз готовий до заміни MOG2 на YOLO без переписування решти системи.

Поки все виглядає як ще один модуль Автобота. Але якщо подивитись трохи ширше, стає помітно цікава закономірність.

Колись робот-пилосос, роботизована рука, автономний візок чи система відеоспостереження будувались як абсолютно різні проєкти. Сьогодні між ними дедалі менше відмінностей. Усі мають камеру, усі отримують потік кадрів, усі виконують детекцію, усі повинні прийняти рішення й передати його іншому модулю. Змінюється лише алгоритм усередині блоку «детекція».

Десять років тому це були контури, кольорові маски, Haar Cascade та інші класичні алгоритми OpenCV. Сьогодні дедалі частіше це YOLO або інші компактні нейромережі. Через це найстабільнішою частиною системи стає не сам алгоритм комп’ютерного зору, а архітектура навколо нього. Саме її цього тижня й довелося будувати.

◆ Чи не зарано думати про архітектуру

Коли я робив BaseDetector, згадав, що Tesla багато років критикували за ставку майже виключно на комп’ютерний зір і нейромережі, тоді як конкуренти використовували камери, радари й LiDAR. Маск називав LiDAR «милицею», стверджуючи, що якщо система навчиться бачити світ так само добре, як людина, камери буде достатньо.

Незалежно від того, хто правий у цій дискусії, для мене цікавіший інший висновок. Найдорожчою частиною системи виявився не алгоритм комп’ютерного зору, а архітектура навколо нього. Сьогодні всередині працює MOG2, завтра — YOLO, післязавтра — інша модель. Але камера, буферизація, запис кліпів, система подій і взаємодія між модулями залишаться майже незмінними. Саме тому я намагаюся будувати не «детектор руху», а модуль комп’ютерного зору, який переживе ще не одну зміну алгоритмів.

◆ Архітектура: просто роздуми вголос

Якщо Автобот вже має окрему аналогову плату з відео-передавачем — стандартна дронна FPV-обв’язка: картинка на пульт для пілота йде аналоговим каналом, незалежно від будь-якої цифрової мережі, — то задача «оператор в курсі» вже реалізована.

А от автономна детекція руху — зовсім інша задача: Автобот сам детектить рух і щось із цим робить, без участі оператора. Для цього потрібна обробка кадру прямо на платі, а не просто трансляція картинки кудись, хай навіть аналоговим каналом. Тому:

  • Аналогове FPV → відео для оператора, окремий канал, не стосується цієї статті
  • Локальна детекція на Pi → рішення, яке приймає сам робот, і воно рахується там же, де приймається рішення про поведінку — на борту, синхронно з подією, без мережевого round-trip кудись і назад

Камера — CSI-модуль безпосередньо на Pi. Детекція — локально, MOG2 на самій платі. Мережа — лише опційний POST-alert, throttled, і якщо його нема — детектор працює так само. Зараз --alert-url просто заглушка: рух є, алерт спрацьовує, але йде в нікуди — так задумано.

Той самий BaseDetector, який відокремив «де рахується YOLO» від «що з цим робити», напрошується ще на один шар абстракції — той самий Adapter, тільки тепер не для детектора, а для каналу сповіщення: AlertChannel з методом send(clip, detections), і кілька реалізацій під нього. HTTP POST — найпростіша й перша. Є й цікавіші варіанти, поки що суто на рівні фантазій: дрон у повітрі з SDR на борту, що піднімає власну міні-GSM-мережу, до якої Автобот чіпляється як до звичайного стільникового зв’язку без стаціонарної інфраструктури внизу; на тому ж дроні — ще один NPU, який додатково розпізнає передане знизу й сам вирішує, що казати далі. Що саме передається каналом — лише алерт-подія чи ціле зображення — теж поки не вирішено.

 

SDR (Software-Defined Radio – програмно-визначена радіосистема) — це технологія радіозв’язку, де базові функції фізичного рівня (фільтрація, модуляція, демодуляція) виконуються за допомогою програмного забезпечення, а не апаратних схем.

Виходить набір модулів-адаптерів, з яких можна зібрати щось конкретне під задачу, коли вона дозріє. AlertChannel як роз’єм-шилд, а що саме в нього встромиться (HTTP, SDR, щось третє) — питання відкрите, поки і так нормально.

◆ Залізо: NoIR-камера, яка бачить більше, ніж потрібно

У проєкті використовується CSI-камера на базі сенсора OV5647 5MP — популярний модуль для Raspberry Pi, але конкретна версія має одну особливість: це NoIR-камера, без інфрачервоного фільтра. Для нічного бачення це плюс — сенсор бачить ІЧ-світло, а два ІЧ-світлодіоди на платі можуть підсвічувати сцену навіть у повній темряві. Побічний ефект — світ, який бачить така камера, не зовсім такий, як бачить людське око.

Звичайна камера має перед сенсором IR-cut фільтр: пропускає видиме світло, блокує інфрачервоне. Людське око цього не помічає, але кремнієвий сенсор бачить значно ширший спектр. Без фільтра сенсор отримує додаткову інформацію з ІЧ-діапазону — чудово для нічного бачення, погано для кольорової картинки вдень. Результат: магентовий відтінок, неправильний баланс білого, неприродні кольори.

Перша реакція на таке майже завжди однакова: «десь неправильні налаштування, треба покрутити параметри» — інший AWB, інші gains, tuning-файли, ручне калібрування. Іноді це допомагає. Але іноді проблема в тому, що інформація вже втрачена на рівні фізики: сенсор не знає, що частина сигналу прийшла від синього світла, а частина — від інфрачервоного. Після того як ці компоненти змішались, алгоритм обробки зображення вже не може повністю відновити оригінальну сцену.

Хороший урок для будь-якого embedded-проєкту: не кожен «баг» виправляється кодом, потрібно спочатку зрозуміти, що саме бачить апаратна частина системи.

⚠ Пастка №0: libcamera-apps більше не існує

sudo apt install -y libcamera-apps

На Raspberry Pi OS (Debian Trixie) це вже не реальний пакет, а transitional dummy-пакет — під капотом тягне справжній rpicam-apps (207 пакетів, ~457MB, бо тягне ще й повний Qt5/GTK-стек для preview-вікна). Бінарники всередині перейменовані: замість libcamera-hello тепер rpicam-hello. Для headless-системи без дисплея краще одразу:

sudo apt install -y rpicam-apps-lite

Перевірка, що камера взагалі підключена:

rpicam-hello --list-cameras
Available cameras
-----------------
0 : ov5647 [2592x1944 10-bit GBRG] (/base/soc/i2c0mux/i2c@1/ov5647@36)
Modes: 'SGBRG10_CSI2P' : 640x480 [62.50 fps - (16, 0)/2560x1920 crop]

Якщо тут порожньо — перевіряти dtoverlay=ov5647 у /boot/firmware/config.txt (Bookworm/Trixie-шлях, не старий /boot/config.txt) і dmesg | grep -i ov5647.

Перше правило робототехніки: спочатку доведи, що сенсор працює, потім пиши алгоритм. Дуже багато часу можна втратити, шукаючи проблему в OpenCV, коли камера взагалі не віддає правильний потік.

◆ Від камери до алгоритму

OV5647 NoIR->libcamera / rpicam->Picamera2->OpenCV->Motion Detector

Камера працює, кадр приходить, OpenCV запускається. А картинка чомусь має неправильні кольори.

⚠ Пастка №1: подвійна конвертація кольору

Перший тестовий міст між Picamera2 і OpenCV виглядав абсолютно логічно:

Результат — колір «неправильний» навіть порівняно з прямим rpicam-still. Причина в логах самого libcamera:

configuring streams: (0) 2592x1944-BGR888/sRGB
create_still_configuration() за замовчуванням вже віддає BGR888, не RGB888. Скрипт додатково робив cvtColor(RGB2BGR) — тобто міняв місцями канали ще раз, на кадрі, який вже й так був у правильному форматі. Подвійний своп замість жодного.

Урок: дефолтний формат picamera2 залежить від типу конфігурації — create_still_configuration() і create_video_configuration() можуть віддавати різні формати за замовчуванням. Завжди перевіряти лог configuring streams, не покладатись на пам’ять «я ж вже це робив у відео-конфігу».

◆ MOG2: простий алгоритм, який допоміг побудувати правильну архітектуру

Коли камера нарешті почала стабільно передавати кадри в OpenCV, здавалось, що найскладніша частина вже позаду. Але отримати зображення — це тільки половина задачі. Набагато цікавіше питання: як навчити систему зрозуміти, що саме в цьому зображенні важливе?

Для першої версії детектора я вибрав не нейромережу, а класичний алгоритм комп’ютерного зору — MOG2. Це не означає, що нейромережеві моделі тут не потрібні: YOLO та подібні відкривають набагато ширші можливості — розпізнавання об’єктів, класифікацію, складніші сценарії. Але на цьому етапі задача була інша: не просто отримати відповідь «ось об’єкт у кадрі», а побудувати весь шлях від камери до події — отримати кадр, обробити його, визначити зміну сцени, зберегти контекст до і після події, передати результат іншому модулю.

MOG2 виявився дуже хорошим першим кроком саме тут: простий алгоритм, не потребує потужного заліза, не використовує GPU чи NPU, добре підходить під поточну конфігурацію Автобота з Raspberry Pi 3B+. Дозволяє зосередитись не на тренуванні моделі, а на фундаментальних питаннях архітектури. Якщо завтра з’явиться Pi 5, а потім плата з нейронним прискорювачем — весь модуль  не доведеться переписувати, зміниться лише внутрішня реалізація детектора: замість MOG2 підключиться YOLO чи інша модель, а камера, буферизація кадрів, система подій і механізм сповіщень залишаться тими самими.

MOG2 відповідає лише за одне питання: чи змінилася сцена? Що робити з цією інформацією — записувати відео, відправляти повідомлення, повернути камеру чи запустити інший сценарій — вже задача наступних рівнів системи.

Ідея алгоритму: він будує модель фону сцени й постійно оновлює її. Якщо новий кадр відрізняється від того, що модель вважає нормальним фоном, ці області потрапляють у маску переднього плану. Для статичної камери це зручний підхід: камера дивиться на сцену, фон поступово вивчається, рухомий об’єкт відрізняється від фону, система генерує подію.

У нашому випадку результат цієї події — не просто прапорець «рух знайдений». Архітектура побудована навколо поняття події: якщо щось відбулося, система повинна зберегти контекст навколо цього моменту. Звідси кільцевий буфер.

Чому просто записати відео після руху недостатньо. На перший погляд логіка очевидна: знайшли рух — почали запис. Але в реальному сценарії це означає втрату найцікавішої частини події: об’єкт входить у кадр, проходить кілька метрів, і лише потім система його помічає. Якщо почати запис у момент спрацювання, у відео не буде моменту появи об’єкта. Тому потрібен буфер із кадрами, які були до події: поки руху немає, система постійно тримає останні кадри в пам’яті; коли MOG2 генерує подію, ці кадри вже доступні, і можна створити повний кліп — що було до руху і що сталося після. Цей підхід типовий для систем відеоспостереження, автомобільних реєстраторів і робототехнічних систем: він розділяє момент виявлення події і момент початку запису.

⚠ Пастка №2: холодний старт MOG2

Перший запуск motion_detect.py:

[motion] score=1.0000 — записую кліп

Здавалося, камера одразу побачила рух. Але в кімнаті нічого не відбувалося.

cv2.createBackgroundSubtractorMOG2() не має готової моделі сцени одразу після створення. Йому потрібен час, щоб зібрати статистику (типово — до 500 кадрів history) і зрозуміти, що є фоном. Перші кадри для нього виглядають так: «я ще нічого не знаю про цю сцену, тому все, що бачу, може бути об’єктом». У результаті весь кадр може потрапити у foreground mask. Це не помилка OpenCV і не нестабільність алгоритму — задокументована поведінка моделі, яка ще не навчилась.

Фікс — фаза прогріву перед початком реального моніторингу:

Тепер перед тим, як реагувати на рух, система дає MOG2 сформувати первинне уявлення про сцену. У реальному роботі завжди є початковий стан, прогрів, затримки, перехідні процеси — і саме вони часто створюють більшість несподіваних проблем, не сам алгоритм.

⚠ Пастка №3: фонова модель «застигає» під час запису кліпу

Найцікавіший баг тижня. Симптом — кліпи писались серіями впритул одна за одною:

motion_20260730_075812.avi
motion_20260730_075816.avi ← +4с
motion_20260730_075821.avi ← +5с
motion_20260730_075827.avi ← +6с

Інтервали майже точно збігались з --post-seconds 5. Причина — у коді запису кліпу:

 

Під час 5-секундного запису кадри писались у файл, але жодного разу не проходили через self.bg.apply(). Фонова модель «застигала» на моменті тригера. Щойно запис завершувався і цикл повертався до звичайного порівняння — перший новий кадр порівнювався зі старою моделлю → знову різниця → знову тригер → знову запис. Каскад.

Фікс — оновлювати модель навіть під час запису:

Після фіксу інтервали між реальними тригерами стали природними (11с, 17с, 19с — коли я справді рухався), а не механічними 4-6с.

⚠ Пастка №4: колір, який неможливо повністю виправити

NoIR без ІЧ-фільтра дає системний magenta/рожевий відтінок — очікувано, задокументовано. Спроби виправити:

  1. ov5647_noir.json tuning-файл (замість дефолтного ov5647.json) — значне покращення, magenta зникає, лишається синювато-сірий зсув.
  2. AwbMode пресети (indoor, daylight, fluorescent) — жодної різниці. Причина: set_controls() викликався до picam2.start() і перезатирався дефолтом при старті пайплайна.
  3. Виправив порядок (start()set_controls()) — контроль почав реально застосовуватись, але жоден із них не дав природного кольору — лише варіації того самого синювато-сірого зсуву.
  4. Ручні ColourGains — кілька комбінацій, жодна не дала природний колір, деякі гірші за автоматичний AWB.

Дослідження показало: це не вирішується софтом у принципі. Синій канал сенсора фізично ловить і видиме синє світло, і частину ІЧ-спектра одночасно — інформація незворотно змішана до того, як доходить до будь-якого AWB-алгоритму. Підтверджено кількома незалежними джерелами (форуми Raspberry Pi, розробник python-picamera). Найкраще software-наближення (awb_auto_is_greyworld для legacy-стеку) саме так себе й описує: прибирає рожевий відтінок, але не дає природного балансу.

Єдине повне рішення — фізичний ІЧ-cut фільтр перед об’єктивом. Механічно те саме, що стоїть у кожній нормальній камері спостереження (ICR — infrared cut removable filter).

Урок: не кожен «баг» це баг коду. Іноді межа проходить на рівні фізики сенсора, і важливо вміти це визначити, а не продовжувати гадати ще годину.

◆ Підсумок тижня

Автобот бачить рух — не ідеальною картинкою, зате без залежності від мережі і на слабкому залізі.

Наступний крок — Місяць 6. ADC, який мав би бути в першому Місяці

Опубліковано в STM32

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *