Praca z sieciami neuronowymi wymaga dużej ilości danych, mocy obliczeniowej oraz poprawnie zdefiniowanej architektury sieci. Wszystkie trzy komponenty składają się na dobre wyniki podczas fazy testowania. Jednak co możemy zrobić gdy nasza sieć daje nam wyniki, dalekie od naszych oczekiwań.
Kontekst
Cała historia zaczyna się w momencie, gdy postanowiłem, że w ramach jednego z moich hobbystycznych projektów będę rozpoznawał captcha. Tak, chodzi o te dziwne obrazki z krzywymi literami. Szybki research w sieci ukierunkował mnie na kilka publikacji w tym temacie [bibcite key=GoodfellowBIAS13,stark2015captcha]. W obu zastosowana idea była raczej prosta, wykorzystano wielowarstwową konwolucyjną sieć neuronową, która na wyjściu przewidywała wektor złożony z N-segmentów każdy dla pojedynczego znaku (cyfra, mała bądź wielka litera). W obu przytoczonych pracach przewidywanych było tylko kilka znaków (max 7), więc wymiar wektora nie był za duży. Ja miałem zbiór obrazów z max 20 znakami, co ostatecznie skutkowało wektorem wyjściowym o wymiarach 20x[10(cyfr)+26(a-z)+26(A-Z)+spacja]=20×63=1260. Stworzyłem sieć oraz funkcje straty (ang. Loss function) uruchomiłem wszystko i niestety wyniki na poziomie ok 45%, w porównaniu do 99.8% z pracy Pana Goodfellow, więc słabo.
Zacząłem się zastanawiać, że może moja funkcja straty jest źle zdefiniowana, że architektura sieci nie taka, że gdzieś mam błąd, tylko jak to przetestować? Pogrążając się w głębokiej rozkminie, mój kumpel widząc moja nietęgą minę, zerknął mi przez ramię i rzucił, stary masz za mało danych oraz przetestuj to na prostszym problemie. No tak, jakie to oczywiste.
No dobra, pełen zapału i werwy ruszyłem dalej do boju, najpierw muszę wygenerować prostszy problem. Postanowiłem rozpocząć od rozpoznawania tylko 2 cyfr, co sprowadza mój wektor wyjściowy do 20 wymiarów.
Generowanie obrazków z użyciem PILLOW
Najważniejsze to mieć cel, mam wygenerować obrazy z dwoma cyframi, hehe super proste, jedziemy z kodem. Mały pythonowy skrypt wykorzystujący bibliotekę PIL, a tak naprawdę PILLOW i jestem w domu. Już położyłem ręce na klawiaturze a pod palcami wskazującymi poczułem literki 'F’ i 'J’, miałem rozpocząć, gdy nagle dotarło do mnie, że aby odwzorować mój pierwotny problem dane powinny być zróżnicowane.
No dobra, zastanówmy się jak je zróżnicować:
- dobrze gdyby cyfry były pisane różnymi czcionkami, eeee to raczej proste, wystarczy ściągnąć kilka przykładowych czcionek (np. ztąd http://www.1001freefonts.com/ ) i wykorzystać je podczas pisania po obrazie
- cyfry powinny być pod różnym kątem
- cyfry powinny być w różnej odległości do siebie
- skrypt powinien być na tyle uniwersalny, że gdy zechcę za moment wygenerować 4,8 czy 10 cyfr nie będę musiał go przerabiać.
Z takimi wymaganiami funkcjonalnymi zabrałem się za kodowanie, w wyniku pracy wyszedł następujący skrypt w pythonie generujący obrazy z losowym ciągiem cyfr, w których każda cyfra jest pod losowym kątem oraz losowo przesunięta.
Całe rozwiązanie wykorzystuje moduły z biblioteki PIL:
- Image – do podstawowych operacji na obrazie,
- ImageFont – do wczytania czcionek TrueType
- oraz ImageDraw – do rysowania po obrazie
Po pierwsze należy zainstalować bibliotekę PIL, a tak naprawdę Pillow, jest to fork nie rozwijanej już biblioteki PIL, zauważcie, że w kodzie wszystkie importy odwołują się jednak do PIL.
pip install pillow
Idea skryptu – składamy obraz z mniejszych obrazków
Na początku importujemy wykorzystywane biblioteki (Linie 1-10). Następnie definiujemy funkcją genDigitsImg, która odpowiada za stworzenie jednego obrazu z cyframi.
Idea polega na tym, że najpierw tworzymy główny duży obraz (img), który służy jako kontener dla mniejszych obrazków z cyframi, następnie w pętli przechodzimy po wszystkich losowo wygenerowanych cyfrach (numbers) dla każdej tworzymy mniejszy obrazek (im1) następnie obracamy go o losowy kąt (angle) oraz przesuwamy (pad_w, pad_h). Wklejamy cyfrę do dużego obrazu.
Bardzo ważne jest dobranie odpowiednich rozmiarów dużego obrazu, aby mógł pomieścić poszczególne cyfry. Należy także uważać na błąd w implementacji funkcji font.getsize (Linia 37 – http://stackoverflow.com/questions/1965466/imagefonts-getsize-does-not-get-correct-text-size), która zwraca wielkość tekstu bez górnego offsetu czcionki, należy dobrać go eksperymentalnie w zależności od użytych czcionek i ich rozmiaru. Z tego właśnie powodu do wysokości obrazku z cyfrą (im1) w Lini 38 dodaję 10px (fh+10).
Funkcja genDigitsImg jako parametry przyjmuje tablicę numbers z losowymi cyframi, obiekt załadowanej czcionki (font), wymiary obrazu (img_size), które należy dostosować do wielkości czcionki oraz ilości losowych cyfr, ostatnie parametry określają kolory tła i tekstu.
from PIL import Image from PIL import ImageDraw from PIL import ImageFont import matplotlib.pyplot as plt import numpy.random as rnd import datetime as dt import time import os def genDigitsImg(numbers,font,img_size=(64,32), colorBackground = "white", colorText = "black"): ''' Generates one image with random digits with specified font numbers - numpy array with digits img_size - tuple of img width and height font - PIL font object Returns =========== img - PIL img object ''' digit_offset=5 dh=-5 #height offset angle_var=20 img = Image.new('RGBA', img_size, colorBackground) for i,nr in enumerate(numbers): digit_str = str(nr) fw, fh=font.getsize(digit_str) im1 = Image.new('RGBA',(fw,fh+10),colorBackground) d1 = ImageDraw.Draw(im1) d1.text( (0,dh),digit_str,font=font, fill=colorText) #d1.rectangle((0, 0, fw-1, fh-1), outline='red') im1sz = im1.size #d1.rectangle((0, 0, im1sz[0]-1, im1sz[1]-1), outline='green') angle = rnd.randint(-angle_var,angle_var) #im1_rot=im1.rotate(angle, expand=1) im1_rot=im1.rotate(angle, resample=Image.BILINEAR, expand=1) #im1_rot=im1.rotate(angle, resample=Image.BICUBIC, expand=1) pad_w = rnd.randint(-5,6) pad_h = rnd.randint(5) pos_w = digit_offset+pad_w #img.paste(im1_rot,(pos_w,pad_h)) img.paste(im1_rot,(pos_w,pad_h),im1_rot) digit_offset=pos_w+im1_rot.size[0] return img
Generowanie, wyświetlanie oraz zapis obrazów
Ostatnia część skryptu definiuje podstawowe parametry, jak wielkość czcionki, ilość cyfr, ilość obrazów do wygenerowania, następnie w pętli ładowane są poszczególne czcionki Linia 85. Wewnętrzna pętla służy do wygenerowania N-obrazów dla wybranej czcionki, w pierwszej kolejności losujemy ciąg cyfr do wygenerowania z wykorzystaniem funkcji choice z biblioteki numpy (Linia 95), wywołujemy naszą funkcję w celu wygenerowania obrazu. Funkcja genDigitsImg stworzy nam obrazy z 4 kanałami (RGBA), aby ułatwić sobie życie w przyszłości lepiej jest dokonać ich konwersji do skali szarości (Linia 107), dzięki czemu będziemy operować na jednej macierzy a nie na 4 dla każdego kanału. Ostatnim krokiem zapisanie obrazu na dysku oraz wyświetlenie co 500 obrazu (tylko w celach informacyjnych, że wszystko jest ok).
font_size = 26 font_names = ["OpenSans-Regular.ttf", "Mothproof_Script.ttf", "Calligraffiti.ttf"] font_path = "fonts/{}" folder='shared/Digits_23' #how many images with one type of font, final dataset has size number_of_images*number_of_fonts number_of_images=1000 #image size img_size=(56,32) #how many digits to generate random_digits=2 for font_name in font_names: font = ImageFont.truetype(font_path.format(font_name), font_size) font_folder = os.path.splitext(font_name)[0] img_save_folder = '{}/{}/'.format(folder,font_folder) if not os.path.exists(img_save_folder): os.makedirs(img_save_folder) for a in range(number_of_images): numbers = rnd.choice(10,random_digits, replace=True) numbers_str = ''.join([str(x) for x in numbers]) img = genDigitsImg(numbers,font,img_size=img_size) #get the font name without extension #font_folder = os.path.splitext(font_name)[0] digit_file = '{}{}_{}.png'.format(img_save_folder,numbers_str,int(time.time()*1000)) #print digit_file #convert to grayscale img = img.convert('L') img.save(digit_file) if a % 500 ==0: print("#{} - time: {}".format(a,dt.datetime.now())) plt.imshow(img,cmap=plt.cm.gray, interpolation='bicubic') plt.show() #time.sleep(0.5)
Podsumowanie
Wpis przedstawia moją motywację oraz sposób generowania obrazów z użyciem Python PIL. Używając go zwróć szczególną uwagę na odpowiednie wymiary obrazów, dla wielu różnych czcionek dobranie odpowiedniego przesunięcia może być problematyczne, co będzie skutkowało tym, że poszczególne cyfry będę zachodziły na siebie lub wychodziły poza obręb obrazu. W skrypcie kilka lini jest zakomentowanych, służyły mi one do rozpoznania opisywanego błędy z funkcją getsize, rysują one prostokąty dookoła cyfr, co wyraźnie pokazuje przesunięcie czcionek względem właściwego miejsca. Zachęcam także do wypróbowania różnych sposobów interpolacji sąsiednich pikseli w funkcji im1.rotate.
Skrypt na moje oko, trochę zbyt prosty. Może coś przeoczyłem, ale sama zmiana czcionki i obrót to trochę mało. Przydałoby się dodać trochę „szumu”, zniekształcić nieco te cyferki. Inaczej to nie jest wielkie wyzwanie dal ML… Dobrym przykładem są skrypty przygotowujące kody CAPCTHA (tam dochodzą zniekształcenia, tło utrudniające odczytanie przez bota, itp.).
Tak, zgadzam się że obrót i inna czcionka nie jest wyzwaniem dla ML.
Moim głównym celem było wykorzystanie Tensorflow do rozpoznawania CAPTCHA’y właśnie, z tym że samych danych nie mogłem udostępnić, więc potrzebowałem jakiegoś zestawu obrazów aby móc napisać na blogu wpis o TensorFlow
* https://ksopyla.com/2016/09/siec-konwolucyjna-rozpoznawanie-cyfr-z-obrazow/
* https://ksopyla.com/2016/09/siec-konwolucyjna-do-rozpoznawania-ciagu-cyfr-czesc-2/
Skrypt ten powstał niejako przy okazji i postanowiłem się poprostu podzielić 🙂