Wykrywanie gestów 3D – czujnik LSM6DSL i STM32Cube.AI

Detekcja gestów jest dość złożoną operacją. Wymaga wykrycia zależności pomiędzy pomiarami z czujników a czasem ich wystąpienia. Szczególnie trudnych wyzwaniem dla klasycznego podejścia do programowania może być wykrywanie gestów 3D. Znakomitym rozwiązaniem dla tego typu zadań jest sztuczna inteligencja i uczenie maszynowe.

W artykule „Czujnik IMU 6 DoF LSM6DSL – odczyt danych z akcelerometru i żyroskopu” przedstawiłem przykład obsługi układu IMU łączącego dwa czujniki inercyjne – akcelerometr i żyroskop. Dzisiaj na podstawie pomiarów z trzech osi akcelerometru przygotujemy aplikację wykorzystującą sieci neuronowe, która pozwoli na wykrywanie gestów 3D.

W przykładzie wykorzystam taki sam zestaw sprzętu, jak we wspomnianym przed chwilą artykule – płytkę ewaluacyjną NUCLEO-L476RG oraz nakładkę X-NUCLEO-IKS01A2. Do wytrenowania modelu sieci neuronowej użyję pakietu Keras i języka Python.

Przygotowanie danych treningowych do procesu uczenia

Aby wykorzystać sieć neuronową, musimy najpierw ją przygotować i wytrenować. Do procesu uczenia wymagana jest pula danych treningowych – im większa i bogatsza w różne scenariusze, tym łatwiej jest osiągnąć dobre wyniki uczenia i działania sieci. Dane treningowe składają się z tensora wejściowego (wektor, macierz) oraz etykiet, które określają oczekiwany wynik działania sieci. Dzięki obu tym elementom w procesie uczenia jesteśmy w stanie dobrać wagi tak, aby sieć działała zgodnie z naszymi oczekiwaniami. Po zaimportowaniu sieci do docelowego projektu będziemy podawali już tylko tensor wejściowy, a sieć na podstawie wyuczonego algorytmu poda nam odpowiedź.

Do obsługi czujnika LSM6DSL wykorzystam bibliotekę opisaną w materiale „Czujnik IMU 6 DoF LSM6DSL – odczyt danych z akcelerometru i żyroskopu„. Do odczytu danych z LSM6DSL zastosujemy interfejs I2C.

W konfiguratorze STM32CubeMX wybieramy w takim razie interfejs I2C1dostępny na pinach PB8 (SCL) oraz PB9 (SDA). Ustawiamy tryb pracy jako I2C i prędkość komunikacji w trybie Standard (100 kHz).

Wykorzystamy również interfejs USART2 (domyślnie skonfigurowany z parametrami 115200 8n1) podłączony do portu USB na płytce Nucleo oraz przycisk B1 i przerwanie EXTI13.

Konfiguracja wyprowadzeń będzie wyglądała jak na grafice poniżej.

Generujemy projekt. Musimy teraz napisać funkcję, która będzie odczytywała dane z akcelerometru oraz wysyłała je przez interfejs USART2 do komputera. Pobrane dane zapiszemy za pomocą terminala RealTerm do pliku.

Funkcja ai_sharp_train_data_collect() wywołuje co 10 ms odczyt pomiarów z akcelerometru i dane zapisuje do tablicy. Jeżeli zgromadzi 100 pomiarów (300 danych) lub wykryte zostanie wciśnięcie przycisku, zgromadzone dane wysyłane są przez USART2 do komputera. Przycisk sygnalizuje, że wykonaliśmy gest, dlatego na koniec rekordu dodana zostanie cyfra „1”. Jeżeli gest nie został wykryty, rekord będzie kończyła cyfra „0”.

void ai_sharp_train_data_collect(void)
{
	uint8_t tx_buffer[TX_BUFFER_SIZE] = {0};
	static uint32_t data_in[DATA_BUFFER_SIZE];

	LSM6DSL_Axes_t accel_axes;
	static uint32_t counter = 0;
	int32_t size = 0;

	static uint32_t time_cnt = 0;

	if(time_cnt == 0)
		time_cnt = HAL_GetTick();

	if((HAL_GetTick() - time_cnt) > TASK_TIME_MS)
	{
		time_cnt = HAL_GetTick();

		lsm6dsl_get_accel_axis(&accel_axes);

		for(uint32_t i=0; i<(DATA_BUFFER_SIZE-3); i+=3 )
		{
			data_in[i] = data_in[i+3];
			data_in[i+1] = data_in[i+4];
			data_in[i+2] = data_in[i+5];
		}

		data_in[DATA_BUFFER_SIZE-3] = accel_axes.x;
		data_in[DATA_BUFFER_SIZE-2] = accel_axes.y;
		data_in[DATA_BUFFER_SIZE-1] = accel_axes.z;

		counter++;

		if(button_is_pushed == true)
		{
			counter = NUMBER_OF_RECORDS;
		}

		if(counter >= NUMBER_OF_RECORDS)
		{
			counter = 0;

			for (int var = 0; var < DATA_BUFFER_SIZE; ++var)
			{
				size = sprintf((char *)tx_buffer, "%d,", (int)data_in[var]);
				HAL_UART_Transmit(&huart2, tx_buffer, size, 10);
			}

			if(button_is_pushed == true)
			{
				size = sprintf((char *)tx_buffer, "1\n");
				button_is_pushed = false;
			}
			else
			{
				size = sprintf((char *)tx_buffer, "0\n");
			}

			HAL_UART_Transmit(&huart2, tx_buffer, size, 1);
		}
	}
}

Funkcja wykorzystuje kilka stałych, które możemy zdefiniować wg własnych potrzeb. W moim przypadku odczyty wykonuję co 10 ms, czyli 100 pomiarów oznacza zebranie danych z 1 s. Pozwoli to na wykrycie szybkich gestów. Jeżeli chciałbyś wykrywać gesty, których wykonanie zajmuje więcej czasu, tutaj można skonfigurować odpowiednie parametry.

#define DATA_BUFFER_SIZE			300
#define NUMBER_OF_RECORDS			100
#define TX_BUFFER_SIZE				30
#define TASK_TIME_MS				10

Dodajemy również obsługę przerwania od przycisku.

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
	if(GPIO_Pin == B1_Pin)
	{
		button_is_pushed = true;
	}
}

W pliku main.c w funkcji głównej wywołujemy inicjalizację czujnika LSM6DSL oraz w pętli while(1) funkcję zbierającą dane.

/* USER CODE BEGIN 2 */
  lsm6dsl_init();
  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
	  ai_sharp_train_data_collect();

    /* USER CODE END WHILE */
  }

Po uruchomieniu programu powinniśmy zapisać przesyłane przez USART2 dane do pliku. Do tego celu wykorzystamy terminal RealTerm. Potrzebna będzie podstawowa konfiguracja. W zakładce Display wybieramy Display As Ansi.

Następnie w zakładce Port wybieramy Baud 115200 oraz właściwy port COM. Następnie klikamy Open.

Aby zapisywać dane do pliku, w zakładce Capture wybieramy plik, do którego chcemy zapisać i klikamy Start Overwrite lub Start Append, w zależności czy chcemy nadpisać dane w pliku, czy nie. Od tego momentu wszystkie dane będziemy mieli logowane do pliku.

W przypadku, gdy wykonamy gest, musimy wcisnąć przycisk. W trakcie generowanie danych treningowych pamiętajmy o tym, aby zebrać ich jak najwięcej, ale też aby reprezentowały różne ruchy, które mogą się wydarzyć w trakcie działania docelowej aplikacji. Dzięki temu sieć będzie w stanie lepiej wykrywać docelowe gesty i prawidłowo reagować na inne. Na poniższym filmie przedstawiam, jaki gest będzie wykrywany przez mój model.

Tworzenie modelu Keras i uczenie sieci neuronowej

Mamy już przygotowane dane treningowe, zatem pora przejść do stworzenia i uczenia modelu sieci. Jak wspominałem w artykule pt. „Sztuczna inteligencja – wstęp do STM32Cube.AI i Keras„, tę część zadania będziemy realizowali za pomocą pakietu Keras i języka Python.

Kod programu do obróbki danych i tworzenia modelu będę pisał w notatnikach Jupyter (Jupyter Notebook) domyślnie dodanych przy instalacji Anaconda. Jest to dość wygodne narzędzie polecane do przetwarzania danych i obliczeń, ponieważ możemy tworzyć fragmenty kodu i uruchamiać je niezależnie, co znacznie skraca czas potrzebny na ich wykonanie – przykładowo dane możemy zaimportować raz na początku, a nie za każdym razem przy trenowaniu modelu.

Pierwszym etapem jest przygotowanie danych treningowych. Musimy je pobrać z pliku oraz odpowiednio przetworzyć. Na czym to przygotowanie danych ma polegać? Przede wszystkich potrzebujemy dwóch zbiorów danych: treningowego i testowego. Na zbiorze treningowych przeprowadzamy proces uczenia sieci, a na zbiorze testowym sprawdzamy, jak sieć radzi sobie z nowymi danymi. Sprawdzanie skuteczności działania sieci na zbiorze treningowym mijałoby się z celem – sieć widziała już te dane i jest na nie przygotowana. Nam zależy na tym, żeby sieć prawidłowo działała także na innych danych wejściowych.

W procesie pobierania danych zebrałem 584 próbek. Podzielimy je na dwa zbory – treningowy i testowy – po połowie. Aby wykorzystać w obu zbiorach maksymalnie zróżnicowane dane, do obu zbiorów wybierzemy co drugą próbkę. Na końcu każdego rekordu znajduje się znacznik określający, czy gest był wykonany, czy nie. Zapiszemy je do oddzielnej tablicy przechowującej wyniki (results).

import numpy as np
from tensorflow.keras.utils import to_categorical

f = open('AI_Accel_Gesture_3D.txt','r')

values = []
results = []

record_size = 300

for line in f:
    fields = line.strip().split(',')
    values.append(fields[:record_size])
    results.append(fields[record_size:(record_size+1)])
        
f.close()

number_of_records = len(results)

train_data = []
train_labels = []
test_data = []
test_labels = []

train_data = np.array(values[:number_of_records:2]).astype('float32')
train_labels = np.array(results[:number_of_records:2]).astype('float32')

test_data = np.array(values[1:number_of_records:2]).astype('float32') 
test_labels = np.array(results[1:number_of_records:2]).astype('float32')

train_labels = to_categorical(train_labels)
test_labels = to_categorical(test_labels)

Ze względu na to, że sieci znacznie lepiej radzą sobie z przetwarzaniem małych liczb, dobrze byłoby przeprowadzić normalizację danych. Ma ona bardzo duże znaczenie, gdy jako dane wejściowe podajemy wartości z różnych zakresów. Normalizacja pozwala ujednolicić dane. W tym celu musimy przekształcić nasze dane do typu float oraz podzielić je przez maksymalną wartość dla naszego pomiaru (2000 mg), dzięki czemu otrzymamy dane z zakresu od -1 do 1.

std = 2000
train_data /= std
test_data /= std

Teraz przechodzimy do utworzenia modelu i procesu uczenia sieci. Jest to dość rozbudowany proces, choć ilość linii kodu na to nie wskazuje. Sposób doboru liczby, typu warstw oraz parametrów uczenia jest dość intuicyjny i bazuje na doświadczeniu użytkownika z sieciami, uczeniem maszynowym i pakietem Keras. Parametry dobieramy na zasadzie metody „prób i błędów”, a im więcej sieci stworzysz i nauczysz, tym łatwiej będzie Ci ocenić już na początku, jakie parametry mogą być skuteczne w danym przypadku. Poniżej przedstawię moją propozycję budowy sieci i parametrów uczenia – jeżeli chcesz zgłębić ten temat, polecam książkę „Deep Learning. Praca z językiem Python i biblioteką Keras” Francois Chollet-a.

Do budowy modelu sieci użyjemy klasy Sequential. Ze względu na typ danych (jednowymiarowy wektor), zastosujemy warstwy typu Dense. Co prawda z logicznego punktu widzenia mamy trzy osie i moglibyśmy podać trzy wektory jako dane wejściowe. Pakiet Keras nie wspiera jednak takiego rozwiązania i wymusza na nas spłaszczenie danych do jednego wektora – dla działania sieci i jej dokładności nie ma to żadnego znaczenia.

W przykładzie użyłem dwóch warstw o rozmiarze 32 i 16 jednostek, z funkcją aktywacji relu. W pierwszej warstwie podajemy rozmiar tensora wejściowego (w naszym przypadku 300), w następnych warstwach rozmiar ten będzie już automatycznie dobierany na podstawie warstwy poprzedniej. Ostatnia warstwa (w naszym przypadku trzecia) powinna mieć rozmiar wyjściowy odpowiadający ilości rodzajów etykiet, czyli u nas 2.

from keras import models
from keras import layers

network = models.Sequential()
network.add(layers.Dense(32, activation='relu', input_shape=(record_size,)))
network.add(layers.Dense(16, activation='relu'))
network.add(layers.Dense(2, activation='softmax'))

Teraz za pomocą funkcji compile konfigurujemy parametry uczenia sieci (optymalizator, funkcję straty oraz monitorowanie dokładności). Wybieramy funkcję straty entropi krzyżowej i optymalizator rmsprop.

network.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['accuracy'])

Teraz możemy wywołać proces uczenia. Wykonamy 10 epok (cykli uczenia) z wsadami danych po 8 próbek.

network.fit(train_data, train_labels, epochs=10, batch_size=8)

Na koniec sprawdzimy działanie sieci na testowym zbiorze danych.

results = model.evaluate(test_data, test_labels)

Utworzona w ten sposób sieć pozwala nam uzyskać dokładność na poziomie 97 %, co jest przyzwoitym wynikiem. Jeżeli stworzylibyśmy większy zbiór danych oraz dopracowali dobór parametrów uczenia, z pewnością bylibyśmy w stanie uzyskać jeszcze lepszy rezultat. Na koniec zapisujemy nasz model na dysku.

network.save('AI_Accel_Gesture_3D.h5')

Możemy przejść do importu zbudowanego i wytrenowanego modelu do programu dla STM32.

Importowanie modelu i przykład dla STM32

Przed nami ostatni etap, czyli importowanie modelu i implementacja w kodzie programu. Wracamy zatem do naszego projektu w STM32CubeIDE i otwieramy konfigurator.

Przechodzimy do okna „Software Packs -> Select Components” (Alt+O) i zaznaczamy okno przy X-CUBE-AI -> Core. Najnowsza dostępna wersja w trakcie pisania programu to 7.1.0. Wykorzystamy domyślny szablon poprzez wybór „Application -> ApplicationTemp”.

Zamykamy okno i w konfiguratorze z listy po lewej stronie wybieramy „Software Packs -> X-CUBE-AI”. Następnie klikamy „Add network”. W oknie dodajemy nazwę (ja zostawiłem domyślną network), typ modelu jako „Keras” i rodzaj jako „Saved model”. Podajemy ścieżkę do zapisanego modelu. W ostatniej części okna możemy wybrać parametry kompresji i rodzaj walidacji (więcej na ten temat można przeczytać we wpisie „STM32Cube.AI – walidacja działania modelu„). Wybieramy przycisk „Analyze”.

Jeżeli wszystko przebiegło pomyślnie, po chwili powinniśmy otrzymać raport. Znajdziemy w nim m.in. informację o modelu (ilość, typ warstw, dane wejściowe, wyjściowe) oraz o ilości pamięci Flash i RAM, jakiej model potrzebuje. W moim przypadku sieć zajmie ok. 52,43 kB Flash oraz 3,41 kB RAM-u.

Generujemy projekt i przechodzimy do napisania kodu. W drzewie projektu po lewej stronie pojawi nam się folder X-CUBE-AI, gdzie mamy pliki z naszą siecią (network, network_data, network_config) oraz z dodaną aplikacją domyślną (app_x-cube-ai). Pliki network zawierają dane dotyczące sieci (m.in. ilość danych wejściowych, wyjściowych), pliki network_data to tablica z wagami wygenerowanymi w procesie uczenia, a plik network_config informuje m.in. o wersji używanego pakiety X-CUBE-AI.

W pliku main() pojawiła się nam funkcja MX_X_CUBE_AI_Init(), która inicjalizuje sieć. Dzisiaj nie będziemy się zagłębiali w jej działanie. W pętli while natomiast dodana została funkcja MX_X_CUBE_AI_Process(), którą musimy zmodyfikować.

W domyślnym wzorcu funkcja MX_X_CUBE_AI_Process() została napisana bardzo ogólnie, dlatego możemy usunąć jej ciało. Dodamy natomiast tablice na dane wejściowe naszej sieci o rozmiarze 300 i typie float oraz zmienną float z wyjściem sieci.

static float data_in[AI_NETWORK_IN_1_SIZE];
static float data_out[AI_NETWORK_OUT_1_SIZE];

Następnie będziemy przypisywali do tablicy danych wejściowych kolejne próbki z pomiaru akcelerometru analogicznie jak w przypadku zbierania danych treningowych. Ponieważ w procesie uczenia używaliśmy danych po normalizacji, tutaj również musimy podzielić wartość przez 2000 mg.

for(uint32_t i=0; i<(AI_NETWORK_IN_1_SIZE-3); i+=3 )
{
	data_in[i] = data_in[i+3];
	data_in[i+1] = data_in[i+4];
	data_in[i+2] = data_in[i+5];
}

data_in[297] = accel_axes.x / 2000.0;
data_in[298] = accel_axes.y / 2000.0;
data_in[299] = accel_axes.z / 2000.0;

Następnie przypisujemy wektory z danymi do globalnych struktur wykorzystywanych przez funkcję ai_run i ją wywołujemy. Spowoduje to przetworzenie danych wejściowych przez sieć i umieszczenie wyników w zmiennej danych wyjściowych.

ai_input[0].data = AI_HANDLE_PTR(data_in);
ai_output[0].data = AI_HANDLE_PTR(data_out);

ai_run();

Następnie sprawdzamy, czy nasza sieć wykryła gest (w drugim elemencie tablicy umieszczone jest prawdopodobieństwo wykrycia gestu). Założyłem, że satysfakcjonuje mnie wynik, jeżeli prawdopodobieństwo wyniesie co najmniej 90 %. Wysyłam informację przez USART2, że gest został wykryty oraz zaświecam diodę.

if(data_out[1] > 0.9)
{
	HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);

	size = sprintf((char *)tx_buffer, "Detect\n");
	HAL_UART_Transmit(&huart2, tx_buffer, size, 10);

	for (uint32_t i = 0; i < AI_NETWORK_IN_1_SIZE; ++i)
	{
		data_in[i] = 0;
	}
}
else
{
	HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
}

Cała funkcję będzie wyglądała jak na listingu poniżej.

void MX_X_CUBE_AI_Process(void)
{
    /* USER CODE BEGIN 6 */
	static float data_in[AI_NETWORK_IN_1_SIZE];
	static float data_out[AI_NETWORK_OUT_1_SIZE];
	LSM6DSL_Axes_t accel_axes;

	int32_t size = 0;
	uint8_t tx_buffer[20] = {0};

	lsm6dsl_get_accel_axis(&accel_axes);

	for(uint32_t i=0; i<(AI_NETWORK_IN_1_SIZE-3); i+=3 )
	{
		data_in[i] = data_in[i+3];
		data_in[i+1] = data_in[i+4];
		data_in[i+2] = data_in[i+5];
	}

	data_in[297] = accel_axes.x / 2000.0;
	data_in[298] = accel_axes.y / 2000.0;
	data_in[299] = accel_axes.z / 2000.0;

	ai_input[0].data = AI_HANDLE_PTR(data_in);
	ai_output[0].data = AI_HANDLE_PTR(data_out);

	ai_run();

	if(data_out[1] > 0.9)
	{
		HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);

		size = sprintf((char *)tx_buffer, "Detect\n");
		HAL_UART_Transmit(&huart2, tx_buffer, size, 10);

		for (uint32_t i = 0; i < AI_NETWORK_IN_1_SIZE; ++i)
		{
			data_in[i] = 0;
		}
	}
	else
	{
		HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
	}
    /* USER CODE END 6 */
}

Funkcję MX_X_CUBE_AI_Process() powinniśmy wywoływać co 10 ms podobnie jak było to przy pobieraniu danych. Dodamy zatem prostą funkcję, którą umieścimy w pętli while(1) w pliku main.c.

void ai_gesture_detect(void)
{
	static uint32_t time_cnt = 0;

	if(time_cnt == 0)
		time_cnt = HAL_GetTick();

	if((HAL_GetTick() - time_cnt) > TASK_TIME_MS)
	{
		time_cnt = HAL_GetTick();
		MX_X_CUBE_AI_Process();
	}
}
/* USER CODE BEGIN 2 */
  lsm6dsl_init();
  /* USER CODE END 2 */

  /* Infinite loop */
/* USER CODE BEGIN WHILE */
  while (1)
  {
       ai_gesture_detect();
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }

Możemy skompilować program i go uruchomić. Sposób działania przedstawiony został na poniższym filmie.

Podsumowanie

W materiale przedstawiłem kolejny przykład dla STM32 wykorzystujący sztuczne sieci neuronowe, który pozwoli nam na wykrywanie dowolnych gestów przy użyciu 3-osiowego akcelerometru. Mam nadzieję, że będzie on stanowił ciekawe rozwinięcie tematu wykrywania gestów 1D przy pomocy czujnika odległości.

Pełny projekt znajdziecie poniżej w repozytorium. Umieściłem w nim także pliki dotyczące pakietu Keras. Jeżeli masz jakieś własne doświadczenia z uczeniem maszynowym i czujnikami ruchu albo uwagi odnośnie przedstawionego przykładu – został komentarz pod artykułem.

Do pobrania

Repozytorium GitHub

Dodaj komentarz

Twój adres e-mail nie zostanie opublikowany. Wymagane pola są oznaczone *