Rozpoznawanie ręcznie pisanych cyfr – przykład z STM32Cube.AI

W dzisiejszym wpisie przedstawię kolejny przykład zastosowania sztucznych sieci neuronowych i pakietu STM32Cube.AI. Zajmiemy się przetwarzaniem obrazów, a konkretnie rozpoznawaniem ręcznie pisanych cyfr na wyświetlaczu dotykowym.

W pierwszym przykładzie przedstawionym w artykule pt. „Wykrywanie gestów 1D – analogowy czujnik SHARP i STM32Cube.AI” proces wykonania projektu składał się z trzech części: przygotowania danych uczących, stworzenie modelu sieci neuronowej i wytrenowanie go oraz implementacja sieci w projekcie przy użyciu pakietu STM32Cube.AI. Dzisiaj przedstawię przykład, gdzie nie będziemy zbierali własnych danych uczących, ale wykorzystamy zbiór danych MNIST.

Baza danych ręcznie pisanych cyfr MNIST zawiera zestaw uczący 60 000 przykładów oraz zestaw testowy 10 000 przykładów. Cyfry zostały znormalizowane pod względem rozmiaru i wyśrodkowane. Jest to dobra baza danych dla osób, które chcą wypróbować techniki uczenia się i metody rozpoznawania wzorców na rzeczywistych danych przy minimalnym wysiłku na wstępne przetwarzanie i formatowanie.

Przygotowanie modelu sieci neuronowej

Ze względu na to, że mamy gotowe dane przygotowane do wytrenowania sieci neuronowej, możemy od razu przejść do budowania modelu. Zestaw danych MNIST możemy pobrać ze strony projektu, ale jest on także dołączony do pakietu Keras, dlatego zaimportujemy go bezpośrednio z keras.datasets.

from keras.datasets import mnist

(train_images, train_labels), (test_images, test_labels) = mnist.load_data()

Obrazy w bazie danych mają wymiary 28 x 28 pikseli oraz są zapisane w skali szarości, czyli każdy piksel przyjmuje wartości od 0 do 255. Na potrzeby zbudowania sieci neuronowej, musimy przetworzyć dane treningowe do postaci tablicy o wymiarze 60000 elementów i liczbach typu float oraz przeskalować zakres od 0 do 255 do zakresu od 0 do 1, aby były łatwiejsze do przetwarzania.

train_images = train_images.reshape((60000, 28, 28, 1))
train_images = train_images.astype('float32') / 255
test_images = test_images.reshape((10000, 28, 28, 1))
test_images = test_images.astype('float32') / 255

Następnie tworzymy tablice z etykietami w formie kategorii.

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

Mamy przygotowane dane treningowe. Możemy zatem przejść do przygotowania struktury modelu sieci neuronowej. Do przetwarzania danych bardzo dobrze sprawdzają się konwolucyjne sieci neuronowe (CNN), które w pakiecie Keras są reprezentowane m.in. przez warstwy Conv2D. Wykorzystamy trzy takie warstwy, a pomiędzy nimi umieścimy warstwy MaxPooling2D, które mają za zadanie skalowanie obrazów w kolejnych warstwach (dążymy do zmniejszania rozdzielczości).

from keras import layers
from keras import models

model = models.Sequential()
model.add(layers.Conv2D(32, (3, 3), activation='relu', input_shape=(28, 28, 1)))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))
model.add(layers.MaxPooling2D((2, 2)))
model.add(layers.Conv2D(64, (3, 3), activation='relu'))

Teraz możemy przeprowadzić spłaszczenie z dwuwymiarowych obrazów do postaci wektorowej. Następnie dodajemy jeszcze dwie warstwy typu gęstego (które stosowaliśmy w przypadku przetwarzania danych z czujnika odległości). Ważną informacją jest rozmiar ostatniej warstwy gęstej, która przyjmuje wartość 10 – określa ona ilość elementów wyjściowych, czyli w naszym przypadku prawdopodobieństwo wystąpienia każdej z dziesięciu cyfr.

model.add(layers.Flatten())
model.add(layers.Dense(64, activation='relu'))
model.add(layers.Dense(10, activation='softmax'))

Strukturę modelu stworzyłem na bazie przykładu z książki „Deep Lerning. Praca z językiem Python i biblioteką Keras” autorstwa Francois Chollet. Jeżeli interesują cię szczegóły budowy sieci, odsyłam do lektury 🙂

Mamy już przygotowany model, zatem czas na przeprowadzenie uczenia się sieci.

model.compile(optimizer='rmsprop', loss='categorical_crossentropy', metrics=['accuracy'])
model.fit(train_images, train_labels, epochs=5, batch_size=64)

Na koniec wywołujemy jeszcze sprawdzenie działania sieci na danych testowych.

model.evaluate(test_images, test_labels)

Przy takiej strukturze sieci otrzymaliśmy dokładność na poziomie 0.9908, co jest przyzwoitym wynikiem. Zapisujemy model wykonując instrukcję save.

model.save('model.h5')

Import modelu do projektu STM32

Konfiguracja mikrokontrolera

W programie wykorzystam zestaw STM32F746NG-Disco z wyświetlaczem LCD i dotykowym panelem. Nie będę skupiał się przy opisie na pełnej konfiguracji interfejsów – jeżeli interesują Cię szczegóły, możesz podejrzeć pełny projekt w moim repozytorium GitHub (sekcja „Do pobrania” pod artykułem).

Do konfiguracji oraz obsługi wyświetlacza i panelu dotykowego, a także pamięci SDRAM podłączonej do interfejsu FMC, wykorzystam biblioteki BSP przygotowane przez producenta. Można je znaleźć w pakiecie STM32CubeF7. W przykładzie będę wykorzystywał tylko kilka plików z biblioteki:

  • stm32746g_discovery_lcd.c i stm32746g_discovery_lcd.h
  • stm32746g_discovery_sdram.c i stm32746g_discovery_sdram.h
  • stm32746g_discovery_ts.c i stm32746g_discovery_ts.h
  • stm32746g_discovery.c i stm32746g_discovery.h

Poza tym będą potrzebne pliki do obsługi sterowników wyświetlacza i panelu dotykowego oraz pliki do obsługi czcionek:

  • Components/ft5336
  • Components/rk043fn48h
  • Components/Common/ts.h
  • Utilities/Fonts

Warto dodać, że ze względu na sposób odwołania się do plików „.h” w bibliotece BSP wykorzystany przez producenta (twórca biblioteki odnosi się do plików podając ścieżki), należy umieścić pliki w głównym folderze projektu. W przeciwnym wypadku będą niewidoczne z poziomu BSP.

Jeżeli chodzi o ustawienia mikrokontrolera z poziomu CubeMX, tworząc projekt wybrałem domyślną konfigurację peryferiów i później wyłączyłem niepotrzebne układy. Do obsługi wyświetlacza potrzebujemy interfejsu LTDC oraz I2C3 do panelu dotykowego. Wykorzystamy przycisk USER_BUTTON podłączony do PI11 w trybie przerwania oraz interfejs FMC do obsługi pamięci SDRAM, na której przechowywany będzie obraz przekazywany do wyświetlacza. Poza tym potrzebujemy, aby włączony był moduł CRC. Wszystkie te peryferia są konfigurowane z poziomu bibliotek BSP, dlatego nie musimy wywoływać ich inicjalizacji.

Konfiguracja X-Cube-AI

Przejdźmy zatem do konfiguracji pakietu X-Cube-AI i importowania modelu. Z poziomu CubeMX przechodzimy do okna „Software Packs -> Select Components” (Alt+O) i zaznaczamy okno przy X-CUBE-AI -> Core. Wykorzystamy domyślny szablon poprzez wybór „Application -> AplicattionTemp”.

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. 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. 364,54 kB Flash oraz 32,23 B RAM-u.

Kod programu

Wszystkie funkcje pisane pod kątem tego programu umieściłem w plikach „AI_Digit_Common.c” oraz „AI_Digit_Common.h”. Ze względu na niedużą złożoność projektu, nie dzieliłem ich na pliki pod kątem ich funkcjonalności.

W funkcji main() przed pętlą główną wywołuję inicjalizację wyświetlacza oraz wyświetlenie elementów na wyświetlaczu.

  LCD_and_TouchScreen_Init();
  LCD_Display_Init_Screen();

W funkcji LCD_and_TouchScreen_Init(), poza samą inicjalizacją wyświetlacza i panelu dotykowego za pomocą biblioteki BSP, konfiguruję także adres przechowywania obrazu oraz wyświetlaną warstwę.

void LCD_and_TouchScreen_Init(void)
{
	BSP_LCD_Init();
	if(BSP_TS_Init(BSP_LCD_GetXSize(), BSP_LCD_GetYSize()) == TS_DEVICE_NOT_FOUND);
	BSP_TS_ITConfig();

	BSP_LCD_LayerDefaultInit(1, SDRAM_DEVICE_ADDR);
	BSP_LCD_SelectLayer(1);
	BSP_LCD_Clear(LCD_COLOR_WHITE);
}

W funkcji LCD_Display_Init_Screen() tworzę prosty interfejs użytkownika. Po prawej stronie umieściłem pole w kształcie kwadratu, na którym będę pisał cyfry. Po lewej stronie umieściłem wirtualną linijkę LED oraz etykiety z numerem rozpoznanej cyfry i dokładnością wyrażoną w procentach.

void LCD_Display_Init_Screen(void)
{
	BSP_LCD_Clear(LCD_COLOR_WHITE);

	BSP_LCD_SetTextColor(LCD_COLOR_BLACK);
	BSP_LCD_FillRect(BSP_LCD_GetXSize() - BSP_LCD_GetYSize(), 0, BSP_LCD_GetYSize(), BSP_LCD_GetYSize());
	Virtual_LED_OFF_All();

	BSP_LCD_SetTextColor(LCD_COLOR_BLACK);
	BSP_LCD_SetFont(&Font16);
	BSP_LCD_DisplayStringAt(50, 20, (uint8_t *)"Digit:", LEFT_MODE);
	BSP_LCD_DisplayStringAt(50, 50, (uint8_t *)"Accuracy:", LEFT_MODE);
}

Przed pętlą while(1) w funkcji main wywołujemy także inicjalizację sieci neuronowej i pakietu X-Cube-AI.

MX_X_CUBE_AI_Init();

W pętli głównej while(1) znajduje się natomiast funkcja obsługująca proces przetwarzania logiki programu.

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

    /* USER CODE BEGIN 3 */
  }

W funkcji AI_Digit_Process() umieściłem prostą maszynę stanów. W zależności od tego, które „z kolei” wciśnięcie przycisku użytkownika nastąpiło, następuje albo przetwarzanie danych przez sieć, albo czyszczenie ekranu do stanu pierwotnego.

void AI_Digit_Process(void)
{
	if(IDLE == process_state || WAIT == process_state)
	{
		return;
	}
	else if(AI_PROCESS == process_state)
	{
		MX_X_CUBE_AI_Process();
		AI_Digit_set_next_state();
	}
	else if(CLEAR_SCREEN == process_state)
	{
		LCD_Display_Init_Screen();
		AI_Digit_set_next_state();
	}
}

Zmienna odpowiadająca za przechowywanie stanu procesu jest zwiększana za pomocą funkcji AI_Digit_set_next_state().

void AI_Digit_set_next_state(void)
{
	process_state++;

	if(CLEAR_SCREEN < process_state)
	{
		process_state = IDLE;
	}
}

Za obsługę wirtualnej linijki wyświetlanej na ekranie odpowiadają trzy proste funkcje.

void Virtual_LED_ON(uint8_t led_number)
{
	uint16_t x, y;

	x = 10;
	y = 10 + led_number * 25;

	BSP_LCD_SetTextColor(LCD_COLOR_GREEN);
	BSP_LCD_FillRect(x, y, 20, 20);
}

void Virtual_LED_OFF(uint8_t led_number)
{
	uint16_t x, y;

	x = 10;
	y = 10 + led_number * 25;

	BSP_LCD_SetTextColor(LCD_COLOR_RED);
	BSP_LCD_FillRect(x, y, 20, 20);
}

void Virtual_LED_OFF_All()
{
	for (int i = 0; i < 10; ++i)
	{
		Virtual_LED_OFF(i);
	}
}

W czarnym obszarze po prawej stronie piszemy cyfry. Za obsługę odpowiada kilka linijek kodu umieszczonych w przerwaniu od ekranu dotykowego. W momencie wykrycia dotknięcia panelu, sprawdzamy pozycję palca i w tym miejscu rysujemy koło o promieniu 12 pikseli. Dzięki temu uzyskujemy ładny efekt pisania po ekranie. Obszar rysowania ograniczyłem do czarnego pola o boku 272 pikseli. Dodatkowo w obsłudze przerwania od przycisku zmieniamy stan procesu.

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_pin)
{
	if(GPIO_pin == LCD_INT_Pin)
	{
		TS_StateTypeDef *Position;
		BSP_TS_GetState(Position);

		if(Position->touchY[0] < 12 || Position->touchX[0] < 220 || Position->touchY[0] > 260 || Position->touchX[0] > 468)
			return;

		BSP_LCD_SetTextColor(LCD_COLOR_WHITE);
		BSP_LCD_FillCircle(Position->touchX[0], Position->touchY[0], 12);
	}
	else if(GPIO_pin == USER_BUTTON_Pin)
	{
		AI_Digit_set_next_state();
	}
}

Przejdźmy teraz do najważniejszego elementu, czyli funkcji przetwarzającej dane w sieci neuronowej MX_X_CUBE_AI_Process().

void MX_X_CUBE_AI_Process(void)
{
    /* USER CODE BEGIN 6 */
	  uint8_t text[20] = {0};
	  uint8_t dst_image[784] = {0};
	  float nn_input[28][28];
	  float nn_output[10];

	  scale_nearest_neighbour_algorithm((uint32_t *)SDRAM_DEVICE_ADDR, dst_image);

	  for(uint32_t y=0; y < 28; y++)
	  {
		  for(uint32_t x=0; x < 28; x++)
		  {
			  nn_input[y][x] = dst_image[y*28+x]/255.0f;
		  }
	  }

	  ai_run(nn_input, nn_output);

	  Virtual_LED_OFF_All();

	  for (int i = 0; i < 10; ++i)
	  {
		  if(nn_output[i] > 0.90)
		  {
			  Virtual_LED_ON(i);

			  BSP_LCD_SetTextColor(LCD_COLOR_BLACK);

			  sprintf((char *)text, (char *)"Digit: %d", i);
			  BSP_LCD_DisplayStringAt(50, 20, text, LEFT_MODE);

			  sprintf((char *)text, (char *)"Accuracy: %d", (int)(nn_output[i]*100));
			  BSP_LCD_DisplayStringAt(50, 50, text, LEFT_MODE);
		  }
	  }
    /* USER CODE END 6 */
}

Tworzymy tablice do przechowywania obrazu o rozdzielczości 28×28 pikseli (zgodnie ze zbudowanym modelem przy pomocy pakietu Keras). Nasz obraz ma 272 x 272 piksele. Musimy go zatem przeskalować. Wykorzystałem do tego bardzo prosty algorytm najbliższego sąsiada.

void scale_nearest_neighbour_algorithm(uint32_t *original_image, uint8_t *prepared_image)
{
	uint32_t ratio;
	uint32_t x,y;
	uint32_t x_offset;

	ratio = BSP_LCD_GetYSize() / 28.0;
	x_offset = BSP_LCD_GetXSize() - BSP_LCD_GetYSize();

	for(uint32_t j=0; j<28; j++)
	{
		for(uint32_t i=0; i<28; i++)
		{
			x = ratio*i;
			y = ratio*j;

			*(prepared_image + j * 28 + i) = *(original_image + (y*BSP_LCD_GetXSize() + x + x_offset)) & 0xFF;
		}
	}
}

Otrzymujemy tablicę jednowymiarową, a do sieci powinniśmy podać macierz dwuwymiarową. Dlatego przekształcamy dane o obrazie, jednocześnie zamieniając zakres wartości pikseli z 0-255 na 0-1 typu float.

for(uint32_t y=0; y < 28; y++)
{
	for(uint32_t x=0; x < 28; x++)
	{
		nn_input[y][x] = dst_image[y*28+x]/255.0f;
	}
}

Teraz możemy wywołać przetwarzanie danych przez sieć.

ai_run(nn_input, nn_output);

W tablicy z odpowiedzią sieci otrzymujemy 10 wartości. Każda z nich to prawdopodobieństwo wystąpienia kolejnych cyfr. Sprawdzając, która cyfra osiągnęła prawdopodobieństwo powyżej 90% wiemy, jaka cyfra została wykryta. Zaznaczamy odpowiednią „diodę” na wirtualnej linijce oraz wypisujemy cyfrę i dokładność jej wykrycia.

for (int i = 0; i < 10; ++i)
{
	if(nn_output[i] > 0.90)
	{
		Virtual_LED_ON(i);

		BSP_LCD_SetTextColor(LCD_COLOR_BLACK);

		sprintf((char *)text, (char *)"Digit: %d", i);
		BSP_LCD_DisplayStringAt(50, 20, text, LEFT_MODE);

		sprintf((char *)text, (char *)"Accuracy: %d", (int)(nn_output[i]*100));
		BSP_LCD_DisplayStringAt(50, 50, text, LEFT_MODE);
	}
}

Efekt działania programu widoczny jest na filmie poniżej.

Podsumowanie

Wykrywanie ręcznie pisanych cyfr jest jednym z najczęściej poruszanych tematów na etapie pozwania uczenia maszynowego. Pozwala w ciekawy sposób poznać zasady działania sieci neuronowych i pokazać, jak można wykorzystać sztuczną inteligencję tam, gdzie standardowe podejście do programowanie się nie sprawdza. Pomimo dość złożonego zagadnienia, mikrokontrolery dobrze sprawdzają się w tym przypadku. Dzięki przedstawionemu rozwiązaniu możemy łatwo zbudować np. panel dotykowy do wprowadzania kodu dostępu. Zamiast cyfr możemy „nauczyć” w analogiczny sposób wykrywania liter lub innych znaków. Mam nadzieję, że przykład rozbudził Twoje zainteresowanie sztuczną inteligencją i zainspiruje do dalszej nauki.

Do pobrania

Repozytorium GitHub

Dodaj komentarz

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