Нейромережа вагою 2 кілобайти: TinyML, Arduino та Raspberry Pi Pico
Про те, як Модель машинного навчання на чипі за $4 за допомогою Arduino розпізнає рухи руки
Трохи ностальгії
Привіт! Перш ніж ми почнемо, дозвольте трохи ностальгії — без неї ця історія не має сенсу.
(Невелика примітка: у моєму блозі паралельно йде цикл статей про моє шестимісячне входження в 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 у цій історії нічого не вчить. Він проганяє вже готову, «сталу» модель.
Весь шлях виглядає так:
┌─ НА КОМП'ЮТЕРІ (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.
Код ініціалізації простий:
#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();
}
Зчитання трьох осей:
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, дістаємо нормальні одиниці тяжіння:
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.
⚠ Важливо. У моєму першому проєкті дані — синтетичні. Я не записував реальні рухи, а згенерував числа програмно: в «спокої» — близькі до нуля з невеликим шумом, для «струсу» — великі стрибки, і так далі. Модель, навчена на синтетиці, навіть працює, але «грубо». Це свідоме спрощення для першого кроку, усе, що нижче — навчальний макет.
Сама модель:
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%».
Тренування:
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 у щось, що потягне мікроконтролер:
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:
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(), один раз:
#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() — чотири кроки на кожному оберті:
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) і відповідні дільники, формули переводу.
8 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарівЧи користувались Edge Impulse? Якщо ні, то рекомедую спробувати.
Там всі ці кроки можна зробити в одному середовищі (записування датасету, навчання моделі, оптимізація, дебаг інференсу, і.т.д.).
Приклад як це виглядає:
docs.edgeimpulse.com/...to-end/motion-recognition
Підтримується багато різних мікроконтроллерів (якщо відсутній у списку, є інструкція як портувати):
docs.edgeimpulse.com/hardware
Дякую, ваш коментар не просто підтримка і підтвердження, що мої письмена справді читають, а ще й супер доповнення для читачів. Так, Edge Impulse знаю, інструмент справді супер, а особливо для збору датасету, це якраз саме те, бо модель навчена на синтетиці. Але ж вся суть пройти пайплайн руками, від train_model.py до model_data.h, щоб розуміти що відбувається, щоб розуміти, чому ці 2 КБ взагалі можливі. Звісно для продакшну, де треба швидко, Edge Impulse просто необхідно
На мою думку, таку задачу на mpu6050 краще вирішувати, повісивши на інтеррапт пін. В ньому все для цього є.
Цікаво було би натренувати нейромережу на фільтрацію, і подивитись як вона з цим впорається
Слушно, але це трохи різні задачі. Interrupt-пін через MOT_THR відповідає на «чи був рух». А тут задача «який саме жест» не просто струшування. Але разом вони працюють красиво: interrupt будить контролер зі сну, і тільки тоді запускається інференс — економія живлення в рази. Дякую, що підсвітили це питання, зацікавило, хочу вже дещо спробувати. Щодо нейромережі на фільтрацію, думаю для фільтрації класичний комплементарний/Калман фільтр буде і легший, і передбачуваніший. Хоча перевірити це цікаво
Цікава стаття, дякую!
У мене вийшов дещо схожий досвід, але на стороні Android. Нещодавно реалізував для свого Android додатку офлайн ML + Computer Vision рішення для розпізнавання друкованого та написаного від руки Морзе-коду з фотографій і кадрів камери.
У підсумку весь AI-модуль займає близько 4.78 МБ при завантаженні, причому більше половини цього обсягу займає сам LiteRT runtime. Власне моделі та прикладна логіка виявилися значно компактнішими, ніж я очікував на початку.
Тому тема ефективного використання ресурсів та запуску моделей безпосередньо на пристрої зараз здається мені особливо цікавою. Приємно бачити на DOU більше матеріалів про TinyML та Edge AI.
Дякую. Це неймовірно цікаво. Радію як дитя зробив wake-word модель на 50KB з AUC 0.98, повністю своя CNN. яка детектить одне українське слово Катя, в терміналі реагує в real-time. Сиджу бавлюсь тепер маю свою Siri ))
Ого, дуже крутий результат)
50 KB для власної моделі та AUC 0.98 виглядає справді вражаюче.
Я правильно зрозумів, що це вже квантована модель, чи такого розміру вдалося досягти ще до квантування?
357KB → 33KB INT8 квантизована модель, AUC 0.971 це вже протестовано в живому inference. Чітко спрацьовує, але прийшлось повозитись, як завжди опишу в черговому дописі