Kod źródłowy tego eksperymentu jest dostępny na GitHubie. Wykorzystujemy bibliotekę Keras w wersji 2.0 (ver. 2.0.4) z TensorFlow jako backendem (ver. 1.2.0-rc2), bazując na Pythonie w wersji 3.6.
Do odtworzenia tego eksperymentu konieczne są dane udostępnione tutaj. Paczkę najlepiej rozpakować w katalogu domowym dla zachowania zgodności z przedstawionym kodem źródłowym.
Archiwum z danymi zawiera następujące elementy:
- obrazy ze zbioru Indoor-67 podzielone na podzbiory train, val oraz test
- wagi sieci Resnet-152 wytrenowane na zbiorze ImageNet, pozyskane z tego miejsca
- plik testowy zawierający mapowanie liczbowych identyfikatorów w ImageNet na nazwy klas, pozyskany z tego miejsca
- obliczone wektory cech z sieci Resnet-152 dla wszystkich obrazów w podzbiorach train, val i test
Resnet-152 w Kerasie
Pierwszym krokiem eksperymentu było pozyskanie modelu sieci Resnet-152 dla Kerasa. Modele sieci Resnet nie są oficjalnie dostępne dla Kerasa, jednak dzięki udostępnionemu tutaj projektowi, możemy pobrać zarówno implementację modelu jak i wagi przekonwertowane bezpośrednio z oficjalnych modeli, które zostały przygotowane dla frameworka Caffe. Implementacja wykorzystuje Kerasa w wersji 1.0, ale wersja dostosowana do wersji 2.0 jest dostępna tutaj i ta właśnie wersja jest załączona w kodzie źródłowym eksperymentu.
Rozpoczniemy od upewnienia się, czy pobrany model poprawnie klasyfikuje obiekty z klas znajdujących się w zbiorze ImageNet. Przygotowaliśmy do tego celu prosty skrypt resnet_demo.py, który pozwala uruchomić sieć na wybranych obrazkach poprzez kopiowanie ich do schowka. Poniżej przykładowe obrazy wraz z wyniki klasyfikacji, wśród których poprawne odpowiedzi są pogrubione:
Klasyfikacja wnętrz budynków i pomieszczeń oraz referencyjna dokładność
Przejdziemy teraz do naszego zadania rozpoznawania wnętrze pomieszczeń. Najpierw przyjrzymy się przykładowym obrazom ze zbiory Indoor-67. Jak widać poniżej, wiele z obrazów jest dość jednoznaczna, jednak część klas jest bardzo podobna, np. fastfood restaurant i restaurant lub library i bookstore, co utrudnia przypisanie obrazu do właściwej klasy.
Zanim zaimplementujemy nasz klasyfikator, przyjrzyjmy się jeszcze referencyjnym wynikom dokładności uzyskiwanym na tym zbiorze w pracach naukowych, dzięki czemu przekonamy się, jaki wynik można uznać za satysfakcjonujący. Autorzy zbioru Indoor-67 przedstawili własną metodę klasyfikacji, jednak dekadę temu problem ten był bardzo trudny do rozwiązania. Metoda ta uzyskała jedynie 26% dokładności. Z biegiem czasu proponowano jednak coraz lepsze metody, aż w 2016 roku uzyskano najwyższy obecnie poziom dokładności równy 79%. Jak zobaczymy za moment, nasze proste podejście uzyska wynik 73%, który jest wprawdzie nieco niższy niż w przypadku obecnie najlepszej znanej metody, ale jednocześnie jest wyższy od najlepszej metody z 2015 roku.
Proponowane podejście
W prezentowanej metodzie zastosujemy wyjście z przedostatniej warstwy sieci Resnet-152, będącej warstwą typu pooling o 2048 wyjściach, wykorzystując je jako wektor cech dla rozpatrywanych obrazów. Wspomniany wektor w oryginalnym modelu stanowi wejście dla ostatniej warstwy, która odpowiedzialna jest za klasyfikację, a zatem zawiera on pewnego rodzaju wiarygodną reprezentację obrazu. Wektor ten traktowany jako wektor cech jest też często nazywany w języku angielskim słowem 'code' lub 'bottleneck'. Warto zauważyć, że jako wektor cech można również użyć wyjść innych, wcześniejszych warstw modelu (może to jednak wymagać jego skompresowania z użyciem poolingu lub innej metody).
Wyliczenie wektorów cech
Aby zapobiec wielokrotnemu wyliczaniu wektorów cech dla poszczególnych obrazów w czasie treningu, z góry wyliczymy wektory cech dla wszystkich obrazów i zapiszemy je na dysku postaci macierzy numpy. Zbiór Indoor-67 oryginalnie podzielony jest zbiory train i test, w eksperymencie tym jednak wydzieliliśmy losowo część obrazów ze zbioru treningowego, tworząc zbiór walidacyjny. Wyliczone wektory cech dla obrazów w każdym z podzbiorów są dołączone w archiwum z danymi, więc ewentualnie można krok ten pominąć, jako ze generowanie wektorów potrwa trochę czasu (może to być ok. 2 godzin).
Poniżej kolejne kroki prowadzące do wygenerowania wektorów cech z modelu Resnet-152:
- Po pierwsze, wczytujemy model sieci Resnet-152 razem z dołączonymi wagami dla zbioru ImageNet (dla ułatwienia dołączonymi w archiwum z danymi), po czym tworzymy na nim nieco mniejszy model, którego wyjściem będą wyjścia warstwy average pooling, zamiast finalnej warstwy klasyfikującej:
from keras.models import Model
from resnet import resnet152
WEIGHTS_RESNET = os.path.expanduser("~/ml/models/keras/resnet152/resnet152_weights_tf.h5")
# Load Resnet 152 model and construct feature extraction submodel
resnet_model = resnet152.resnet152_model(WEIGHTS_RESNET)
feature_layer = 'avg_pool'
features_model = Model(inputs=resnet_model.input,
outputs=resnet_model.get_layer(feature_layer).output)
- Przed przekazaniem obrazów na wejście modelu, musimy pamiętać o właściwym wstępnym przetworzeniu obrazów. W szczególności musimy skonwertować przestrzeń kolorów z RGB, które jest domyślnym formatem przy wczytywaniu obrazów za pomocą skimage, na format BGR, który jest oczekiwany przez pobrany przez nas model Resnet-152. Musimy także przeskalować obraz do odpowiedniego rozmiaru oraz przeskalować wartości pikseli z domyślnie wczytanego przedziału <0, 1> do przedziału <0, 255>. Bardzo ważne jest, aby od wartości pikseli obrazu odjąć średnią ze zbioru treningowego. W tym przypadku, średnia ta ma postać 3 liczb odpowiadających kanałom BGR, reprezentującym średnie wartości pikseli w zbiorze treningowym dla każdego z kanałów. Przygotujemy więc oddzielną funkcję dla całego procesu przetwarzania wstępnego:
import numpy as np
import skimage.transform
def preprocess(im):
"""
Preprocesses image array for classifying using ImageNet trained Resnet-152 model
:param im: RGB, RGBA float-type image or grayscale image
:return: ready image for passing to a Resnet model
"""
# Some special cases handling
# …
# RGB to BGR
im = im[:, :, ::-1]
# Resize and scale values to <0, 255>
im = skimage.transform.resize(im, (224, 224), mode='constant').astype(np.float32)
im *= 255
# Subtract ImageNet mean
im[:, :, 0] -= 103.939
im[:, :, 1] -= 116.779
im[:, :, 2] -= 123.68
# Add a dimension
im = np.expand_dims(im, axis=0)
return im
- Teraz możemy wczytać obrazy ze zbioru i uruchomić na nich nasz model, aby uzyskać wektory cech:
# Load image
im = skimage.io.imread(path)
im = helper.preprocess(im)
# Run model to get features
code = features_model.predict(im).flatten()
W ten sposób wyznaczymy wektory cech dla całego zbioru obrazów i zapiszemy je w tablicy razem z przypisanymi do nich identyfikatorami klas, które ustalamy na podstawie ścieżek obrazów, jako że zawierają one nazwy klas. Na końcu zapisujemy komplet wektorów cech oraz etykiet na dysk. Przetwarzamy tym sposobem każdy z podzbiorów train, val i test oddzielnie, generując oddzielne zestawy wektorów i etykiet. Cały proces może potrwać około 2 godzin przy wykorzystaniu CPU, będzie to jednak kwestia minut jeśli zastosujemy GPU.
import json
import os
import skimage.io
import numpy as np
DATA_SUBSETS = [
os.path.expanduser("~/ml/data/indoor/train"),
os.path.expanduser("~/ml/data/indoor/val"),
os.path.expanduser("~/ml/data/indoor/test"),
]
FEATURES_FILENAME = "features-resnet152.npy"
LABELS_FILENAME = "labels-resnet152.npy"
PATHS_FILENAME = "paths-resnet152.json"
NAMES_TO_IDS = json.load(open("names_to_ids.json"))
# For each data subset
for datadir in DATA_SUBSETS:
features = []
labels = []
images_list = glob.glob(datadir + "/*/*.jpg")
# Process images
for path in images_list:
# Load image
im = skimage.io.imread(path)
im = helper.preprocess(im)
# Run model to get features
code = features_model.predict(im).flatten()
# Cache result
label = NAMES_TO_IDS[os.path.basename(os.path.dirname(path))]
labels.append(label)
features.append(code)
# Save to disk
np.save(os.path.join(datadir, FEATURES_FILENAME), features)
np.save(os.path.join(datadir, LABELS_FILENAME), np.uint8(labels))
Trening
Po wygenerowaniu wektorów cech możemy przejść do trenowania klasyfikatora.
- Po pierwsze, wczytujemy wygenerowane wcześniej wektory i etykiety ze zbiorów treningowego i walidacyjnego, które będą stanowić wejście dla klasyfikatora. Etykiety skonwertujemy jednak do postaci wektorów typu one-hot za pomocą funkcji to_categorical() dostępnej w Kerasie.
import os
import numpy as np
import keras
TRAIN_DIR = os.path.expanduser("~/ml/data/indoor/train")
VAL_DIR = os.path.expanduser("~/ml/data/indoor/val")
FEATURES_FILENAME = "features-resnet152.npy"
LABELS_FILENAME = "labels-resnet152.npy"
# Load train data
train_features = np.load(os.path.join(TRAIN_DIR, FEATURES_FILENAME))
train_labels = np.load(os.path.join(TRAIN_DIR, LABELS_FILENAME))
train_labels = keras.utils.np_utils.to_categorical(train_labels)
# Load val data
val_features = np.load(os.path.join(VAL_DIR, features))
val_labels = np.load(os.path.join(VAL_DIR, labels))
val_labels = keras.utils.np_utils.to_categorical(val_labels)
- Następnie stworzymy nową warstwę klasyfikacji, którą wytrenujemy pod kątem naszego zadania rozpoznawania wnętrz. Zdefiniujemy zaledwie jedną warstwę typu full-connected (w Kerasie noszącej nazwę Dense). Zainicjalizujemy wagi wartościami z wylosowanymi z uciętego rozkładu normalnego, natomiast stałe składowe (biases) - zerami. Jako że rozwiązujemy problem klasyfikacji typu jeden-do-wielu, użyjemy funkcji aktywacji softmax. Jako optymalizatora użyjemy SGD (Stochastic Gradient Descent). Jako learning rate, użyjemy wartości 0.1, która dała dobre rezultaty w weryfikacji eksperymentalnej. Wreszcie, jako funkcję kosztu do optymalizacji wykorzystamy categorical_crossentropy, a jako wartość monitorowaną w czasie treningu wybierzemy dokładność (accuracy).
from keras.layers import Dense
from keras.models import Sequential
from keras.optimizers import SGD
# Build softmax model
classifier_model = Sequential()
classifier_model.add(Dense(67, activation='softmax',
kernel_initializer='TruncatedNormal',
bias_initializer='zeros',
input_shape=train_features.shape[1:]))
# Define optimizer and compile
opt = SGD(lr=0.1)
classifier_model.compile(optimizer=opt, loss='categorical_crossentropy', metrics=['accuracy'])
- Zdefiniujemy również kilka callbacków. Po pierwsze, będziemy redukować learning rate w trakcie treningu, co usprawnia proces optymalizacji. Po drugie, w czasie treningu zapisywać będziemy najlepszy uzyskany model - ModelCheckpoint domyślnie zapisywać będzie model o najniższej wartości kosztu dla zbioru walidacyjnego, co jest nam potrzebne, aby uniknąć overfittingu.
WEIGHTS_CLASSIFIER = "classifier_weights.h5"
# Prepare callbacks
lr_decay = ReduceLROnPlateau(factor=0.9, patience=1, verbose=1)
checkpointer = ModelCheckpoint(filepath=WEIGHTS_CLASSIFIER,
save_best_only=True,
verbose=1)
- W tym momencie możemy uruchomić trening:
# Train
classifier_model.fit(train_features, train_labels,
epochs=50,
batch_size=256,
validation_data=(val_features, val_labels),
callbacks=[lr_decay, checkpointer])
Trening powinien potrwać około minuty, wyjście będzie wyglądać mniej więcej tak:
Epoch 27/50
256/4690 [>.............................] - ETA: 0s - loss: 0.1456 - acc: 1.0000
1792/4690 [==========>...................] - ETA: 0s - loss: 0.1585 - acc: 0.9877
3584/4690 [=====================>........] - ETA: 0s - loss: 0.1556 - acc: 0.9877
4690/4690 [==============================] - 0s - loss: 0.1547 - acc: 0.9883 - val_loss: 1.0582 - val_acc: 0.6910
Epoch 00026: val_loss improved from 1.06202 to 1.05825, saving model to classifier_weights.h5
Test
Po ukończeniu treningu możemy przetestować klasyfikator.
- Najpierw wczytamy zapisane wektory cech oraz etykiety ze zbioru testowego:
import os
import numpy as np
TEST_DIR = os.path.expanduser("~/ml/data/indoor/test")
FEATURES_FILENAME = "features-resnet152.npy"
LABELS_FILENAME = "labels-resnet152.npy"
# Load test data
test_features = np.load(os.path.join(TEST_DIR, FEATURES_FILENAME))
test_labels = np.load(os.path.join(TEST_DIR, LABELS_FILENAME))
- Następnie skonstruujemy klasyfikator oraz wczytamy wytrenowane wcześniej wagi:
import os
from keras.layers import Dense
from keras.models import Sequential
WEIGHTS_CLASSIFIER = "classifier_weights.h5"
# Load top layer classifier model
classifier_model = Sequential()
classifier_model.add(Dense(67, activation='softmax', input_shape=test_features.shape[1:]))
classifier_model.load_weights(WEIGHTS_CLASSIFIER)
- Wreszcie uruchomimy klasyfikator na danych testowych i zmierzymy liczbę poprawnych odpowiedzi:
from collections import defaultdict
import numpy as np
# Classify the test set, count correct answers
all_count = defaultdict(int)
correct_count = defaultdict(int)
for code, gt in zip(test_features, test_labels):
code = np.expand_dims(code, axis=0)
prediction = classifier_model.predict(code)
result = np.argmax(prediction)
all_count[gt] += 1
if gt == result:
correct_count[gt] += 1
# Calculate accuracies
print("Average per class acc:",
np.mean([correct_count[classid] / all_count[classid]
for classid in all_count.keys()]))
W końcu otrzymujemy dokładność klasyfikatora.
Average per class acc: 0.73175781553
Demo
Na koniec przekonamy się na własne oczy jak działa nasz klasyfikator. Uruchomimy pełny proces klasyfikacji, zawierający również ekstrakcję cech, aby zobaczyć jak klasyfikator zachowuje się na poszczególnych obrazkach.
- Najpierw wczytamy oba modele: model ekstrakcji cech (Resnet) oraz model klasyfikatora:
import os
from keras.layers import Dense
from keras.models import Model, Sequential
from resnet import resnet152
WEIGHTS_RESNET = os.path.expanduser("~/ml/models/keras/resnet152/resnet152_weights_tf.h5")
WEIGHTS_CLASSIFIER = "classifier_weights.h5"
# Load Resnet 152 model and construct feature extraction submodel
resnet_model = resnet152.resnet152_model(WEIGHTS_RESNET)
feature_layer = 'avg_pool'
feature_vector_size = int(resnet_model.get_layer(feature_layer).output.shape[-1])
features_model = Model(inputs=resnet_model.input,
outputs=resnet_model.get_layer(feature_layer).output)
# Load classifier model
classifier_model = Sequential()
classifier_model.add(Dense(67, activation='softmax', input_shape=[feature_vector_size]))
classifier_model.load_weights(WEIGHTS_CLASSIFIER)
- Iterując po obrazach testowych, wykonujemy klasyfikację i prezentujemy wyniki:
import glob
import json
import os
import random
import numpy as np
import skimage.io
from matplotlib import pyplot as plt
import helper
IDS_TO_NAMES = json.load(open("ids_to_names.json"))
# Load test images
paths = glob.glob(os.path.expanduser("~/ml/data/indoor/test/*/*.jpg"))
random.shuffle(paths)
# Classify images
for path in paths:
print("Classifying image: ", path)
# Load and preprocess image
im = skimage.io.imread(path)
transformed = helper.preprocess(im)
if transformed is None: continue
# Classify
code = features_model.predict(transformed).reshape(1, -1)
prediction = classifier_model.predict(code)
# Print result
prediction = prediction.flatten()
top_idx = np.argsort(prediction)[::-1][:5]
for i, idx in enumerate(top_idx):
print("{}. {:.2f} {}".format(i + 1, prediction[idx], IDS_TO_NAMES[str(idx)]))
# Show image
skimage.io.imshow(im)
plt.show()
Poniżej kilka losowo wybranych przykładów:
Podsumowanie
W tym prostym eksperymencie dotyczącym klasyfikacji zdjęć wnętrz uzyskaliśmy przyzwoitą dokładność na poziomie 73%, co jest odrobinę lepszym wynikiem niż w przypadku metod z ostatnich lat, i wynikiem o jedynie 6% niższym w stosunku do obecnego state-of-the-art. Przedstawione podejście ma ogólny charakter i może być zastosowane do wielu innych zadań klasyfikacji. Warto zwrócić uwagę, że całkowity czas treningu trwa około 2 godzin bez użycia karty graficznej, podczas gdy trening samej warstwy klasyfikującej trwa zaledwie minutę.
Możliwe jest oczywiście wprowadzenie kilku usprawnień do przedstawionego podejścia. Po pierwsze, warto dotrenować więcej warstw sieci lub nawet całą sieć wykorzystując mniejsze learning rate, w przeciwieństwie do zamrożenia niemal całej sieci i trenowania jedynie ostatniej warstwy. Taki trening zajmie oczywiście znacznie więcej czasu. Rozwiązaniem pośrednim byłoby użycie wyjść jednej z wcześniejszych warstw jako wektora cech oraz jego skompresowanie dla rozwiązania problemu dużego rozmiaru - np. poprzez zastosowanie poolingu. Ponadto, dobrym pomysłem jest przeprowadzenie przeszukiwania przestrzeni hiperparametrów w celu znalezienia lepszych wartości, zwłaszcza w przypadku learning rate, polityki jego redukcji, oraz wielkości batch size. W tym przykładzie wartości te zostały dobrane eksperymentalnie, jednak lepszym wyborem jest zastosowanie automatycznego przeszukiwania, np. w formie zwykłego losowania wartości z określonych przedziałów, co pozwoli przetestować znacznie większą liczbę zestawów parametrów. Wreszcie, warto również przyjrzeć się dokładniej danym, z którymi mamy do czynienia oraz zastosować odpowiednie techniki augmentacji.
Bibliografia
[1] Quattoni, Ariadna, and Antonio Torralba. "Recognizing indoor scenes." Computer Vision and Pattern Recognition, 2009. CVPR 2009. IEEE Conference on. IEEE, 2009.
[2] Zhou, Bolei, et al. "Learning deep features for scene recognition using places database." Advances in neural information processing systems. 2014.
[3] Khan, Salman H., et al. "A discriminative representation of convolutional features for indoor scene recognition." IEEE Transactions on Image Processing 25.7 (2016): 3372-3383.
[4] Herranz, Luis, Shuqiang Jiang, and Xiangyang Li. "Scene recognition with CNNs: objects, scales and dataset bias." Proceedings of the IEEE Conference on Computer Vision and Pattern Recognition. 2016.
wstecz