Про те, як Модель машинного навчання на чипі за $4 розпізнає рухи руки
Замість вступу — трохи ностальгії
Привіт! Перш ніж ми почнемо, дозвольте трохи ностальгії — без неї ця історія не має сенсу.
(Невелика примітка: у моєму блозі паралельно йде цикл статей про моє шестимісячне входження в embedded — день за днем, плата за платою, від голих регістрів STM32 до Linux на крихітному комп’ютері Luckfox. Ця стаття стоїть осібно і читається сама собою, але якщо вам близька тема — там продовження.)
Багато років тому, коли слово «IoT» ще не було так популярно, я будував свій маленький інтернет речей. Тільки він був на дротах. Arduino Pro Mini, Ethernet-модуль W5500, кручена пара. Мої пристрої спілкувалися один з одним по сокетах через простенький сигнальний сервер. Я наглядав за насосною станцією, датчиком кортролював тиск води, наглядав за електрокотлом температурними датчиками. SMS-розсилка, дрібна автоматика, термінал знижок. Wi-Fi я тоді не довіряв і, чесно кажучи, не дуже довіряю зараз: ефір — це втрачені пакети, ризик, що школота заглушить сигнал, ну і хакери бо припускаю і такий ризик, особливо після перегляду серіалу Селіконова долина, хто бачив зрозуміє.
Мої пристрої вміли передавати дані, але не розуміли. Датчик тиску відправляв число. Десь на сервері це число порівнювалося з пороговим значенням, записаним рукою: «якщо більше — тривога». Уся «кмітливість» системи була набором рукописних if-ів.
Довгі роки мріяв навчитися робити так, щоб залізо саме розуміло, що означають отримані ним числа прям на мікроконтролері. Сьогодні вже очевидно що майбутнє IoT саме у маленьких пристроях, які думають локально.
◆ Розділ 1. Чому Pi Pico і Ардуїно і що таке TinyML
TinyML — машинне навчання на мікроконтролерах — як і багато чого іншого вивчав по книзі. «TinyML Cookbook» від технічного лідера у Machine Learning Group компанії Arm Джан Марко Іодіче. Класна книга, практична, з наскрізними проєктами. Приклади розглядались в ній на Arduino Nano 33 BLE Sense і в десять разів дешевшій Raspberry Pi Pico. Ось і вся таємниця «чому Pico»: не тому що вона технічно краща, а тому що дешево.
Тепер — що таке TinyML.
Машинне навчання має два зовсім різні етапи: навчання і застосування.
Навчання (training) — це важко. Береться багато прикладів, модель тисячі разів їх переглядає, помиляється, підкручує себе. Це вимагає потужного заліза — звичайно ПК з хорошою відеокартою. Жоден мікроконтролер цього не робить і робити не буде.
Застосування (inference) — це легко. Модель уже навчена, її параметри сталі. Лишається чиста арифметика: взяти вхідні числа, прогнати через готову модель, отримати відповідь.
TinyML — це спосіб взяти модель, навчену на «великому» комп’ютері, стиснути її так, щоб вона влізла в кілька кілобайт, і запустити її inference на крихітному чипі. Pico у цій історії нічого не вчить. Він проганяє вже готову, «сталу» модель.
Весь шлях виглядає так:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
┌─ НА КОМП'ЮТЕРІ (Python) ─────────────────────────┐ │ │ │ 1. Keras / TensorFlow │ │ описуємо модель, тренуємо на даних │ │ ↓ │ │ 2. Конвертер TFLite │ │ стискаємо, квантизуємо → файл model.tflite │ │ ↓ │ │ 3. Перетворення у C-масив │ │ model.tflite → model_data.h │ │ │ └──────────────────────────────────────────────────┘ ↓ копіюємо у проєкт ┌─ НА PI PICO (C++) ───────────────────────────────┐ │ │ │ 4. TFLite Micro проганяє модель │ │ датчик → inference → відповідь │ │ │ └───────────────────────────────────────────────────┘ |
Keras і TensorFlow живуть тільки на кроці 1, тільки на комп’ютері. На Pico приїжджає лише результат — стала модель у вигляді масиву байтів. Між ними конвертер, який стискає модель.
Ми будемо розпізнавати жести. До Pico по шині I2C підключений акселерометр-гіроскоп MPU6050. Модель має навчитися розрізняти чотири стани руху. Жодного рядка if про «якщо прискорення більше за X». Замість рукописних правил — модель, яка вивела ці правила сама, подивившись на приклади.
⚠ Чесно про основу. Усе, що нижче — мій перший власний TinyML-проєкт, зібраний за мотивами «TinyML Cookbook». Книга показує жести через сервіс Edge Impulse; я ж пішов іншим шляхом — зібрав ланцюг напряму через Keras і TFLite Micro, щоб бачити кожен крок руками. Тому це не переказ книги, а мій власний шлях — з усіма набитими ґулями, які чесно опишу далі.
◆ Розділ 2. Залізо: MPU6050 і шина I2C
MPU6050 — це акселерометр і гіроскоп в одному корпусі. Нам у цьому проєкті потрібен акселерометр: він вимірює прискорення по трьох осях X, Y, Z. У стані спокою він орієгтується на земне тяжіння — це наш постійний орієнтир: вісь, спрямована вгору, показує приблизно 1g.
Підключення до Pico — чотири дроти: живлення, земля і дві лінії шини I2C — SDA (дані) і SCL (тактування). У коді вони висять на пінах GP16 і GP17.
I2C це типу коротка розмова за адресами. У MPU6050 адреса 0x68 та цього не достатньо, треба знати ще два регістри:
Регістр 0x6B — керування живленням. Після подачі струму MPU6050 спить, будимо записуючи нуль в 0x6B.
Регістр 0x3B — звідси починаються дані акселерометра. Це наче поштова скринька: кажемо «хочемо читати з 0x3B», і чип віддає шість байтів поспіль — по два на кожну вісь X, Y, Z.
Код ініціалізації простий:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <Wire.h> const int MPU_ADDR = 0x68; void setup() { Serial.begin(115200); Wire.setSDA(16); Wire.setSCL(17); Wire.begin(); // будимо MPU6050: пишемо 0 у регістр живлення 0x6B Wire.beginTransmission(MPU_ADDR); Wire.write(0x6B); Wire.write(0); Wire.endTransmission(); } |
Зчитання трьох осей:
|
1 2 3 4 5 6 7 8 |
Wire.beginTransmission(MPU_ADDR); Wire.write(0x3B); // починаємо з регістра 0x3B Wire.endTransmission(false); // false — не відпускаємо шину Wire.requestFrom(MPU_ADDR, 6, true); // просимо 6 байтів int16_t raw_ax = (Wire.read() << 8 | Wire.read()); int16_t raw_ay = (Wire.read() << 8 | Wire.read()); int16_t raw_az = (Wire.read() << 8 | Wire.read()); |
Кожна вісь визначена двома байтами — старший і молодший. Зсуваємо старший на 8 біт, склеюємо з молодшим — отримуємо ціле число зі знаком від —32768 до 32767.
Але це «сирі» числа і вони нам ні про що не говорять. MPU6050 за замовчуванням налаштований на діапазон ±2g. Ділимо сире число на 16384, дістаємо нормальні одиниці тяжіння:
|
1 2 3 |
float ax = raw_ax / 16384.0; float ay = raw_ay / 16384.0; float az = raw_az / 16384.0; |
Тепер az ≈ 1.0, коли датчик лежить рівно. Це і є числа, які побачить нейромережа.
⚠ Звідки 16384. Це проста арифметика. Діапазон ±2g розкладений на 16 біт зі знаком. Половина діапазону (2g) відповідає максимуму 32768. 32768 / 2 = 16384 одиниць на кожен g. Зміните діапазон датчика на ±4g — дільник стане 8192. Це класичні граблі: взяв чужий код з іншим діапазоном, забув про дільник — і модель отримує числа не того масштабу.
◆ Розділ 3. Навчаємо модель
Тепер найцікавіше — звідки беруться «мізки». Відкриваємо Python. Це робиться один раз, на комп’ютері, у Google Colab або локально — будь-де, де є TensorFlow.
Модель навчається на прикладах: набір вимірювань акселерометра, і мітки, який це жест. Чотири класи: спокій, рух вгору, рух вниз, струс. Мітки — числа 0, 1, 2, 3.
⚠ Важливо. У моєму першому проєкті дані — синтетичні. Я не записував реальні рухи, а згенерував числа програмно: в «спокої» — близькі до нуля з невеликим шумом, для «струсу» — великі стрибки, і так далі. Модель, навчена на синтетиці, навіть працює, але «грубо». Це свідоме спрощення для першого кроку, усе, що нижче — навчальний макет.
Сама модель:
|
1 2 3 4 5 6 |
import tensorflow as tf model = tf.keras.Sequential([ tf.keras.layers.Dense(16, activation='relu', input_shape=(3,)), tf.keras.layers.Dense(4, activation='softmax') ]) |
Три рядки — і це повноцінна нейромережа.
Sequential — модель як стопка шарів: дані входять зверху, проходять шар за шаром, виходять знизу.
Dense — «повнозв’язний» шар. Кожен його нейрон з’єднаний з усіма входами. Один шар Dense робить просту річ: бере вхідні числа, множить їх на свою таблицю ваг, додає зсув. Множення таблиці чисел це і є вся «математика» нейромережі. Те, що на серйозному залізі робить NPU, тут робить процесор Pico.
input_shape=(3,) — на вході три числа: ax, ay, az. Один знімок акселерометра.
Dense(16) — перший шар має 16 нейронів. Це його «місткість»: замало — не вистачить пам’яті вивчити жести, забагато — модель завчить приклади напам’ять і провалиться на нових. Для чотирьох простих жестів 16 це норм.
activation='relu' — після множення кожен нейрон пропускає результат через ReLU. ReLU: від’ємне обнуляє, додатне лишає. Без цього кілька шарів множення математично згорнулися б в одне, і глибина б нічого не давала. ReLU «ламає» цю лінійність — саме завдяки їй мережа здатна вчити складні, нелінійні правила.
Dense(4, activation='softmax') — останній шар має 4 нейрони, по одному на клас. softmax перетворює їхні сирі числа на чотири ймовірності, що в сумі дають одиницю. Якщо вихід [0.02, 0.05, 0.91, 0.02] — модель каже: «це клас 2, струс, упевненість 91%».
Тренування:
|
1 2 3 4 5 |
model.compile(optimizer='adam', loss='sparse_categorical_crossentropy', metrics=['accuracy']) model.fit(X, y, epochs=50, validation_split=0.2) |
loss — міра помилки: наскільки сильно модель зараз бреше. optimizer='adam' — механізм, який після кожної порції прикладів підкручує ваги, щоб помилка меншала. epochs=50 — пройти весь набір даних 50 разів. validation_split=0.2 — відкласти п’яту частину прикладів і перевіряти точність на них; це чесна оцінка, бо ці приклади модель при навчанні не бачила.
Запускаємо — і спостерігаємо, як accuracy повзе вгору. Модель навчається.
Тепер найважливіше перетворення — з Python у щось, що потягне мікроконтролер:
|
1 2 3 |
converter = tf.lite.TFLiteConverter.from_keras_model(model) converter.optimizations = [tf.lite.Optimize.DEFAULT] tflite_model = converter.convert() |
Optimize.DEFAULT — це і є квантизація.
Натренована модель зберігає ваги як float32 — числа з комою, кожне по 4 байти, точні. Мікроконтролер таке тягне погано бо і пам’яті мало, і float-арифметика дорога. Квантизація переводить усі ваги у int8 — цілі числа від —128 до 127, по 1 байту. Модель меншає вчетверо, рахується швидше. Ціна — мікроскопічна втрата точності, для нашого завдання непомітна.
Останній крок — модель .tflite це файл, а у мікроконтролера немає файлової системи. Тому файл перетворюємо на масив байтів просто в коді C:
|
1 2 3 4 5 6 7 8 9 |
def convert_to_c_array(data): hex_array = ', '.join([f'0x{b:02x}' for b in data]) return f"const unsigned char model[] = {{\n {hex_array}\n}};\n" \ f"const int model_len = {len(data)};" with open('model_data.h', 'w') as f: f.write("#ifndef MODEL_DATA_H\n#define MODEL_DATA_H\n\n") f.write(convert_to_c_array(tflite_model)) f.write("\n\n#endif\n") |
На виході — файл model_data.h. Усередині масив model[] і його довжина. У моєму проєкті модель вийшла 2284 байти. Два з гаком кілобайти — ось це і вся «розумна» частина пристрою.
Цей файл ми кладемо поряд зі скетчем — і переходимо на Pico.
◆ Розділ 4. Inference на Pico: як модель оживає в C++
На комп’ютері ми модель створили й заморозили. Тепер запустимо її на чипі за чотири долари.
Спершу — про бібліотеку, бо тут є нюанс. Inference на Pico виконує TFLite Micro — урізана версія TensorFlow для мікроконтролерів. Я брав її як Arduino-бібліотеку Chirale_TensorFlowLite з офіційного каталогу Arduino.
⚠ Про вибір бібліотеки.
Chirale_TensorFlowLiteофіційно протестована на платах Arduino Nano 33 BLE, Nicla, Portenta, Giga, Nano ESP32. Raspberry Pi Pico у списку підтверджених немає — автори прямо пишуть: сумісність з іншими платами не гарантується, перевіряйте самі. Я перевірив: під ядром arduino-pico воно збирається і працює. Якщо хочете шлях саме для Pico — єpico-tflmicro, офіційний порт TFLite Micro під Pico SDK. Я лишився на Arduino-варіанті свідомо: весь проєкт у мене в Arduino IDE, і для навчання простіше тримати все в одному середовищі. Також є простіша обгорткаArduTFLiteз API в «ардуїнівському» стилі — але я взяв нижчий рівень навмисно, щоб бачити інтерпретатор і тензори напряму.
У TFLite Micro чотири дійові особи. Знайомимось:
Model — той самий масив із model_data.h.
Tensor Arena — шматок оперативної пам’яті, який ми вручну виділяємо під робочі потреби нейромережі. Усі проміжні обчислення відбуваються тут. Розмір підбирається під модель; для нашої вистачає кількох кілобайт.
Interpreter — двигун. Бере вхідні числа, проганяє їх крізь шари моделі, кладе результат на вихід.
Resolver — економіст. Нейромережі бувають з різних математичних операцій, і TFLite Micro знає їх усі. Resolver підключає лише ті, що потрібні саме нашій моделі: множення повнозв’язного шару, ReLU, softmax.
Налаштування — у setup(), один раз:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
#include <TensorFlowLite.h> #include <tensorflow/lite/micro/all_ops_resolver.h> #include <tensorflow/lite/micro/micro_interpreter.h> #include "model_data.h" tflite::MicroInterpreter* interpreter; TfLiteTensor* input; TfLiteTensor* output; constexpr int kTensorArenaSize = 2 * 1024; uint8_t tensor_arena[kTensorArenaSize]; void setup() { // ... ініціалізація MPU6050 з Розділу 2 ... const tflite::Model* model = tflite::GetModel(model_data); static tflite::AllOpsResolver resolver; static tflite::MicroInterpreter static_interpreter( model, resolver, tensor_arena, kTensorArenaSize); interpreter = &static_interpreter; interpreter->AllocateTensors(); // роздаємо пам'ять у арені input = interpreter->input(0); // вказівник на вхідний тензор output = interpreter->output(0); // вказівник на вихідний } |
AllocateTensors() — момент, коли інтерпретатор розкладає по арені всі вхідні, вихідні й проміжні тензори. input і output — це наші «вікна»: у перше ми записуємо дані датчика, з другого читаємо відповідь.
І головний цикл loop() — чотири кроки на кожному оберті:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
void loop() { // 1. ЗБІР — читаємо акселерометр (код з Розділу 2) float ax, ay, az; readAccelerometer(&ax, &ay, &az); // 2. НОРМАЛІЗАЦІЯ — кладемо у вхідний тензор input->data.f[0] = ax; input->data.f[1] = ay; input->data.f[2] = az; // 3. INVOKE — команда "думай" interpreter->Invoke(); // 4. ЧИТАННЯ — шукаємо клас з найбільшою ймовірністю int best = 0; float best_p = output->data.f[0]; for (int i = 1; i < 4; i++) { if (output->data.f[i] > best_p) { best_p = output->data.f[i]; best = i; } } const char* names[] = {"Спокій", "Вгору", "Вниз", "Струс"}; Serial.print("Жест: "); Serial.print(names[best]); Serial.print(" упевненість: "); Serial.println(best_p); delay(100); } |
Invoke() — це і є inference. Один рядок, за яким ховається весь прохід крізь шари: множення на ваги, ReLU, softmax. Те, що на комп’ютері описувалося в Keras, тут просто виконується.
⚠ Квантизація б’є саме тут. Код вище подає у модель
floatі читаєfloat— це працює, поки модель з float-входом. Якщо ж ви квантизували модель повністю (вхід і вихід теж у int8), числа треба перетворювати вручну:int8 = float / scale + zero_pointна вході, і навпаки на виході.scaleіzero_point— два числа, які конвертер прикладає до кожного тензора. Забули перетворення — модель отримає сміття замість даних. Це найпідступніші граблі TinyML: код компілюється, пристрій працює, відповіді — випадкові. Тому тестуйте, перевірте, чи збігаються типи даних на вході.
◆ Розділ 5. Запуск
Заливаємо скетч, відкриваємо Serial Monitor. Pico читає акселерометр, проганяє кожен знімок крізь модель, друкує жест.
Кладемо датчик на стіл — «Спокій». Трусимо — «Струс». Працює. Чип за чотири долари справді розрізняє рух — без жодного рукописного if, лише силою 2284 байтів натренованих вагів.
А тепер про межу, і її важливо розуміти.
«Спокій» і «Струс» модель розрізняє впевнено. А от «Вгору» і «Вниз» — плутає. Чому?
Тому що наша модель дивиться на один миттєвий знімок — три числа в один момент часу. А рух «вгору» — це не момент, це процес: розгін, пік швидкості, гальмування. У різні миті цього процесу акселерометр показує ну дуже різне. Гірше того: один кадр посеред руху вгору і один кадр посеред руху вниз можуть виглядати майже однаково. Модель просто фізично не має звідки взяти відповідь — їй показують одну точку там, де відповідь захована в послідовності точок.
«Спокій» і «Струс» по одному кадру видно: спокій — числа майже не рухаються, струс — великі стрибки. А напрямок руху по одному кадру не видно нікому, навіть ідеальній моделі.
Ось чому я від самого початку чесно попереджав про синтетичні дані й «навчальний макет». Справжній жест-розпізнавач будується інакше — і це рівно те, що відрізняє цей перший крок від наступного.
◆ Розділ 6. Що далі
Цей проєкт — простий перший крок. Його завдання лише показати ланцюг TinyML від Keras до чипа. Що можна зробити.
Перше — справжні дані замість синтетики. Замість згенерованих чисел треба записати реальні рухи: робите жест, Pico шле дані акселерометра в Serial, ви збираєте їх у файл, ставите мітку. По кілька сотень прикладів на жест. Модель, навчена на справжніх рухах, знає шум саме вашого датчика і саме вашу манеру руху — і впевненість стрибає з «якось працює» до 95+ відсотків.
Друге — час. Модель має дивитися не на один кадр, а на вікно — послідовність кадрів за секунду-півтори руху. Тоді «вгору» і «вниз» стають розрізнюваними, бо різниця між ними живе саме в розгортці у часі. Це міняє форму входу моделі й ускладнює і збір даних, і архітектуру — але саме це перетворює макет на робочу річ.
Про що можна написати далі, якщо на цей опус буде відгук.
Є плата Luckfox Pico Pro — крихітний комп’ютер за $15, на якому працює повноцінний Linux. І в нього на борту є те, чого немає ні в Pico, ні в ESP32: NPU — окремий апаратний прискорювач нейромереж. Те, що тут робив процесор Pico в поту, NPU робить без напряга і майже миттєво.
Можна узяти ту саму ідею — розпізнавання за даними датчика — і перенести її на NPU Luckfox: інший рантайм, інший формат моделі, апаратне прискорення. А далі — щоб рішення нейромережі йшло по UART на мікроконтролер, і той замикав реле. Один пристрій думає, інший діє. Але це вже окрема історія.
Дякую, що дочитали.
Довідники:
tinyml_pico_guide.md— покрокове налаштування: ядро arduino-pico, бібліотекаChirale_TensorFlowLite, заливка скетча, типові помилки збірки.mpu6050_i2c_cheatsheet.md— карта регістрів MPU6050, режими діапазону (±2g/±4g/±8g/±16g) і відповідні дільники, формули переводу.uart_wiring_cheatsheet.md— окреме питання, яке часто плутає: два, три чи чотири проводи у UART, і чому. Я й сам роками користувався двома, доки не зрозумів усю картину.