본문 바로가기
ML & DL/tensorflow

교통 표지판 분류 예제

by 별준 2020. 11. 26.

(tensorflow v2.3.1)

이번에는 위와 같은 교통 표지판을 분류하는 모델을 만들어보겠습니다.

기본 내용은 실전활용! 텐서플로 딥러닝 프로젝트를 참고하였습니다.

 

분류에 필요한 데이터는 아래 경로에서 다운받으실 수 있습니다.

benchmark.ini.rub.de/?section=gtsrb&subsection=dataset#Imageformat

 

German Traffic Sign Benchmarks

Dataset Overview Single-image, multi-class classification problem More than 40 classes More than 50,000 images in total Large, lifelike database Reliable ground-truth data due to semi-automatic annotation Physical traffic sign instances are unique within t

benchmark.ini.rub.de

저는 GTSRB_Final_Training_Images.zip과 GTSRB_Final_Test_Images.zip, GTSRB_Final_Test_GT.zip을 사용하였습니다.

GTSRB_Final_Training_Images.zip를 압축해제하면 각 라벨 별로 폴더가 존재하고 그 안에 각 라벨에 해당하는 이미지가 있습니다.

training data

라벨은 0 ~ 42, 총 43개의 종류로 분류됩니다.

그리고 테스트 용으로 사용할 GTSRB_Final_Test_Images.zip와 GTSRB_Final_Test_GT.zip를 해제하면, 라벨별이 아닌 그냥 이미지가 있습니다.

GTSRB_Final_Test_Images.zip에는 라벨이 붙어있지 않기 때문에, 비교를 위해서 GTSRB_Final_Test_GT.zip를 압축해제하시면 .csv에 테스트 이미지의 라벨이 있습니다.

이미지는 .ppm 포맷입니다.

 

사용되는 package는 다음과 같습니다.

import matplotlib.pyplot as plt
import glob
import cv2
import numpy as np
import pandas as pd
import tensorflow as tf
from sklearn.model_selection import train_test_split

1. Data 전처리

사용될 표지판 이미지의 정보를 살펴보시면, 각 이미지에는 하나의 교통 표지판이 있고, 이미지의 사이즈는 15x15에서 250x250 픽셀로 다양합니다. bounding box에 대한 정보도 있지만, 이번 예제에서는 사용하지 않을 것이기 때문에 일단 무시합니다.(나중에 yolo 알고리즘을 연습할 때, 사용해도 괜찮을 것 같네요)

 

그리고 책에서는 이미지 전처리 과정에서 RGB를 LAB로 변환했지만, 우선 RGB로 학습을 해보도록 하겠습니다.

 

우선 label의 수와 변환할 이미지의 크기 변수를 설정하고 training set과 test set을 읽어들일 함수를 정의합니다. 

N_CLASSES = 43
RESIZED_IMAGE = (32, 32, 3)

def read_trainset_ppm(path, n_labels, resize_to):
    images = []
    labels = []
    
    for i in range(n_labels):
        label_path = path + '/' + format(i, '05d') + '/'
        
        for img_file in glob.glob(label_path + '*.ppm'):
            img = cv2.imread(img_file)
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
            
            if resize_to:
                if resize_to[0] < img.shape[0]:
                    img = cv2.resize(img, (resize_to[0], resize_to[1]), interpolation=cv2.INTER_AREA)
                else:
                    img = cv2.resize(img, (resize_to[0], resize_to[1]), interpolation=cv2.INTER_CUBIC)
            
            label = np.zeros((n_labels, ), np.float32)
            label[i] = 1.0
            
            images.append(img.astype(np.float32))
            labels.append(label)
    
    x_train = np.array(images, np.float32)
    y_train = np.array(labels, np.float32)
    
    return x_train, y_train

def read_testset_ppm(path, resize_to):
    images = []
    labels = []
    
    for img_file in glob.glob(path + '*.ppm'):
        img = cv2.imread(img_file)
        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
        
        if resize_to:
            if resize_to[0] < img.shape[0]:
                img = cv2.resize(img, (resize_to[0], resize_to[1]), interpolation=cv2.INTER_AREA)
            else:
                img = cv2.resize(img, (resize_to[0], resize_to[1]), interpolation=cv2.INTER_CUBIC)
                
        images.append(img.astype(np.float32))
    
    labels_path = path + '/GT-final_test.csv'
    annotations = pd.read_csv(labels_path, sep=';')
    
    x_testset = np.array(images, np.float32)
    y_testset = tf.one_hot(annotations['ClassId'], depth=N_CLASSES).numpy()
    
    return x_testset, y_testset

데이터 전처리 과정은 다음과 같습니다.

  1. 라벨별 폴더가 있는 path와 label의 수, 변환할 이미지 크기를 인자로 받으며, 각 폴더별로 이미지를 읽습니다.
  2. 이미지는 openCV를 사용해서 읽습니다. 그리고, openCV는 이미지를 BGR 채널로 읽고 matplotlib은 RGB 채널로 이미지를 표시하기 때문에, matplotlib을 사용하기 위해서 BGR채널을 RGB로 변환해주는 작업을 해줍니다.
  3. 이미지의 크기를 일정한 크기로 변환해줍니다. 보통 사이즈를 줄일 때에는 보간법(interpolation으로 cv2.INTER_AREA를 많이 사용하고, 사이즈를 키울 때에는 cv2.INTER_CUBIC을 많이 사용합니다.
  4. 폴더별로 이미지를 읽고 있기 때문에 같은 폴더 내에는 동일한 라벨로 설정해줍니다.(각 라벨별 확률로 output y를 설정합니다.)
  5. x_train, y_train을 numpy array로 변환해서 반환합니다.

testset도 거의 동일하지만, testset은 라벨별로 폴더가 따로 구분되어 있지 않기 때문에, 한 번에 읽습니다. 그리고 csv파일을 읽어서 ClassId만 뽑아서 라벨 정보로 사용합니다. 마찬가지로 one_hot encoding처리를 위해서 tf.one_hot 메소드를 통해서 각 라벨별 확률로 output을 나타냅니다.

이렇게 데이터를 읽어주면, training set으로 39209개의 샘플, test set으로 12630개의 샘플이 있는 것을 확인할 수 있고, 이미지의 shape는 (32, 32, 3)으로 나타납니다.

시속 20km 제한 표시판으로 보이네요. 

 

마지막으로 training data를 train set과 validation set으로 분리합니다. 

sklearn에 data를 분리해주는 아주 좋은 함수가 있으므로, 이것을 한 번 사용해보도록 하겠습니다.

# set train and val set
from sklearn.model_selection import train_test_split
idx_train, idx_val = train_test_split(range(x_train_orig.shape[0]), test_size=0.2, random_state=101)
X_train = x_train_orig[idx_train,:,:,:]
y_train = y_train_orig[idx_train,]
X_val = x_train_orig[idx_val,:,:,:]
y_val = y_train_orig[idx_val,]

print(X_train.shape)
print(y_train.shape)
print(X_val.shape)
print(y_val.shape)

train_test_split인데, 전체 샘플에서 validation set으로 사용할 비율을 정해서, 랜덤하게 나누어줍니다.

전체 샘플의 20%를 validation data로 설정해서 train set으로 31367개, validation set으로 7842개로 나누었습니다.

 

(추가) 그리고 Nomalization을 진행해줍니다.

# normalization
X_train /= 255.
X_val /= 255.

※ X_val과 y_val을 아래에서 X_test, y_test로 사용한 케이스가 있습니다. 아래 코드의 X_val과 y_val을 X_test와 y_test로 사용한 것과 동일하니 참조바랍니다 !

 

 

2. Model 구현 및 학습

참고한 책에서는 한 가지 모델로만 학습을 했는데, 저는 3~4가지의 모델로 한 번 테스트해보려고 합니다.

 

첫 번째 모델

첫 번째 모델은 책과 동일합니다. 다만, 책은 tensorflow v1로 구현되어 있기 때문에 제가 임의로 조금 수정을 했습니다.

def model1(input_shape=(32, 32, 3)):
    tf.random.set_seed(8)
    model = tf.keras.models.Sequential([
        tf.keras.layers.Input(shape=input_shape),
        tf.keras.layers.Conv2D(filters=32, kernel_size=(5,5), strides=(1,1),
                              kernel_initializer=tf.keras.initializers.glorot_uniform()),
        tf.keras.layers.MaxPooling2D((2,2)),
        tf.keras.layers.Conv2D(filters=64, kernel_size=(5,5), strides=(1,1),
                              kernel_initializer=tf.keras.initializers.glorot_uniform()),
        tf.keras.layers.MaxPooling2D((2,2)),
        tf.keras.layers.Flatten(),
        tf.keras.layers.Dense(1024, activation='relu',
                             kernel_initializer=tf.keras.initializers.glorot_uniform()),
        tf.keras.layers.Dense(N_CLASSES, activation='softmax',
                             kernel_initializer=tf.keras.initializers.glorot_uniform())
    ])
    
    model.summary()
    return model

초반부는 Conv2D와 MaxPool로 구성되고, feature를 펼쳐준 후에 FC층으로 신경망 후반부를 구성합니다.

최적화 알고리즘으로는 Adam(learning rate=0.001)을 사용하고, batch size=256, epochs=10로 학습을 진행해보도록 하겠습니다.

m1 = model1()
m1.compile(optimizer=tf.keras.optimizers.Adam(0.001),
          loss='categorical_crossentropy',
          metrics=['acc'])
h1 = m1.fit(X_train, y_train, batch_size=256, epochs=10, validation_data=(X_test, y_test))

거의 100%의 train 정확도와 92.5%의 validation 정확도를 얻었습니다. train acc가 더 높은 것으로 보아 overfitting 문제가 발생한 것으로 확인됩니다. 

m1.evaluate(X_test, y_test)

test set으로 평가해보니, 약 92.5%의 정확도를 보이고 있습니다. 

 

추가로 각 라벨 별로 precision, recall, f1-score를 살펴볼 수 있는 함수가 있습니다. 참고하시기 바랍니다.

더보기
from sklearn.metrics import classification_report

y_test_pred = m1.predict(X_test)
y_test_pred_classified = np.argmax(y_test_pred, axis=1).astype(np.int32)
y_test_true_classified = np.argmax(y_test, axis=1).astype(np.int32)
print(classification_report(y_test_true_classified, y_test_pred_classified))

 

loss와 acc의 변화 추이를 살펴보겠습니다.

epochs = [i for i in range(1, len(h1.history['acc']) + 1)]
plt.plot(epochs, h1.history['loss'], label='train loss')
plt.plot(epochs, h1.history['val_loss'], 'r', label='val loss')
plt.title('loss')
plt.legend()
plt.show()

plt.plot(epochs, h1.history['acc'], label='train acc')
plt.plot(epochs, h1.history['val_acc'], 'r', label='val acc')
plt.title('accuracy')
plt.legend()
plt.show()

val loss가 어느정도 감소했다가, 증가하고 감소하는 것이 반복적으로 일어나고 있습니다. val acc 또한 어느 순간부터 정체되고 낮아지기도 하는 양상을 보이고 있습니다.

 

두 번째 모델

overfitting을 해결하기 위해서, 첫 번째 모델에서 dropout layer를 추가해보도록 하겠습니다.

def model2(input_shape=(32, 32, 3)):
    tf.random.set_seed(8)
    model = tf.keras.models.Sequential([
        tf.keras.layers.Input(shape=input_shape),
        tf.keras.layers.Conv2D(filters=32, kernel_size=(5,5), strides=(1,1),
                              kernel_initializer=tf.keras.initializers.glorot_uniform()),
        tf.keras.layers.MaxPooling2D((2,2)),
        tf.keras.layers.Dropout(0.2),
        tf.keras.layers.Conv2D(filters=64, kernel_size=(5,5), strides=(1,1),
                              kernel_initializer=tf.keras.initializers.glorot_uniform()),
        tf.keras.layers.MaxPooling2D((2,2)),
        tf.keras.layers.Dropout(0.2),
        tf.keras.layers.Flatten(),
        tf.keras.layers.Dense(1024, activation='relu',
                             kernel_initializer=tf.keras.initializers.glorot_uniform()),
        tf.keras.layers.Dropout(0.4),
        tf.keras.layers.Dense(N_CLASSES, activation='softmax',
                             kernel_initializer=tf.keras.initializers.glorot_uniform())
    ])
    
    model.summary()
    return model

Conv2D - MaxPooling2D layer를 지나고나면 20%를 dropout 시키고 다음 layer로 진행합니다. 그리고 FC layer에서 마지막 output layer로 나갈 때, 40%를 dropout 하도록 하였습니다.

m2 = model2()
m2.compile(optimizer=tf.keras.optimizers.Adam(0.001),
          loss='categorical_crossentropy',
          metrics=['acc'])
h2 = m2.fit(X_train, y_train, batch_size=256, epochs=10, validation_data=(X_test, y_test))

train acc는 첫 번째 모델보다 약간 감소했지만, val acc가 1%정도 증가한 것을 볼 수 있습니다. overfitting이 아주 약간 해결이 된 것으로 보이지만 조금 부족한 것 같습니다.

m2.evaluate(X_test, y_test)

test set으로 평가한 결과도 1%정도 증가한 93.5%의 정확도를 나타내고 있습니다.

꽤 좋은 결과이긴 하지만, 여전히 overfitting의 흔적이 남아 있는 것으로 보입니다.

 

세 번째 모델

세번째는 조금 더 deep한 모델을 사용해보려고 합니다. 사전 학습된 VGG16 모델을 가져와서 top 부분만 재학습해보도록 할 것입니다.

VGG16_base = tf.keras.applications.VGG16(weights='imagenet', include_top=False, input_shape=(32,32,3))
VGG16_base.summary()

우선 VGG16 모델 이후의 Dense layer를 쌓아서 확장해보도록 하겠습니다. VGG16 모델의 output이 (1,1,512)이기 때문에 확장할 layer의 처음 input은 (1,1,512)가 됩니다.

def FC_model():
    tf.random.set_seed(8)
    model = tf.keras.models.Sequential([
        tf.keras.layers.Flatten(input_shape=(1,1,512)),
        tf.keras.layers.Dense(512, activation='relu',
                             kernel_initializer=tf.keras.initializers.glorot_uniform()),
        tf.keras.layers.Dropout(0.4),
        tf.keras.layers.Dense(N_CLASSES, activation='sigmoid')
    ])
    
    model.compile(optimizer=tf.keras.optimizers.Adam(0.001),
                 loss='categorical_crossentropy',
                 metrics=['acc'])
    model.summary()
    return model

m3 = FC_model()

feature_train = VGG16_base.predict(X_train)
feature_val = VGG16_base.predict(X_val)
feature_test = VGG16_base.predict(X_test)

m3.fit(feature_train, y_train, batch_size=256, epochs=10, validation_data=(feature_val, y_val))

그리고 입력으로 사용되는 feature들은 VGG16의 output이기 때문에 FC layer들의 입력으로 사용될 feature들을 먼저 구하고 그 output을 입력으로 FC layer를 학습합니다.

꽤 성능이 좋지 않네요....

 

미리 학습된 VGG16의 마지막 일부 layer들도 같이 학습을 해보도록 하겠습니다.

tf.random.set_seed(8)
VGG16_model = tf.keras.models.Sequential([
    VGG16_base,
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(512, activation='relu',
                         kernel_initializer=tf.keras.initializers.glorot_uniform()),
    tf.keras.layers.Dropout(0.4),
    tf.keras.layers.Dense(N_CLASSES, activation='softmax',
                         kernel_initializer=tf.keras.initializers.glorot_uniform())
])

VGG16_model.summary()

그리고 VGG16의 stage 5만 학습을 진행하고 나머지는 동결시키도록 합니다.

VGG16_base.trainable = True

set_trainable = False
for layer in VGG16_base.layers:
    if layer.name == 'block5_conv1':
        set_trainable = True
    if set_trainable:
        layer.trainable = True
    else:
        layer.trainable = False

print('훈련되는 가중치의 수:', len(VGG16_model.trainable_weights))

학습을 진행합니다. 참고로, trainable을 수정하면 compile을 다시 해주어야지 적용됩니다.

VGG16_model.compile(optimizer=tf.keras.optimizers.Adam(0.001),
                 loss='categorical_crossentropy',
                 metrics=['acc'])
history = VGG16_model.fit(X_train, y_train, batch_size=256, epochs=10, validation_data=(X_val, y_val))

두 번째 모델과 유사하지만, train acc가 조금 더 낮은 모습을 보여주고 있습니다.

VGG16_model.evaluate(X_test, y_test)

오히려 test acc는 많이 낮아졌고, loss 또한 너무 크게 나오고 있습니다. 

하지만 loss와 acc를 살펴보면 대체로 이상적으로 loss는 감소하고 있고, acc는 증가하는 모습을 보여주는 것 같습니다...

학습을 조금 더 진행해보도록 하겠습니다.

 

history_2 = VGG16_model.fit(X_train, y_train, batch_size=256, epochs=10, validation_data=(X_val, y_val))

계속해서 loss가 감소하고 있고, 미세하게 acc가 증가하고 있습니다. 

하지만 여전히 test acc는 그리 좋은 성능을 보이고 있지 않고, 10 epoch 더 반복 학습을 했지만 결과는 유사했습니다.

이번에는 learning rate를 10분의1로 감소시켜서 10 epoch 더 학습해보도록 하겠습니다.

VGG16_model.compile(optimizer=tf.keras.optimizers.Adam(0.0001),
                 loss='categorical_crossentropy',
                 metrics=['acc'])
VGG16_model.fit(X_train, y_train, batch_size=256, epochs=10, validation_data=(X_val, y_val))
VGG16_model.evaluate(X_test, y_test)

train loss는 감소했지만, validation loss는 큰 변화가 없으며, test loss는 더 증가한 모습을 보여주고 있습니다.

 

아무래도 큰 모델이고, 입력의 크기가 (32, 32, 3)으로 작기 때문에, 학습 데이터에 너무 overfitting된 것으로 추측됩니다.

 

마지막으로 learning rate를 더 감소(1e-5)시켜서 학습해보고 다른 모델 ResNet으로 테스트해보도록 하겠습니다.

무언가 더 오래 학습시키면 test 성능이 더 좋아질 것만 같기도 하지만, 일단 넘어가도록 하겠습니다.

 

네 번째 모델

이번엔 ResNet50을 사용해보도록 하겠습니다.

ResNet_base = tf.keras.applications.ResNet50(include_top=False, weights='imagenet', input_shape=(32,32,3))
ResNet_base.summary()

보다시피 매우 큰 모델입니다.. ! ResNet을 거치면 (1,1,2048)의 output이 나오고 우리는 이것을 feature로 FC layer를 쌓아서 VGG16에서 했던 방식으로 그대로 해보겠습니다.

def FC_model2():
    tf.random.set_seed(8)
    model = tf.keras.models.Sequential([
        tf.keras.layers.Flatten(input_shape=(1,1,2048)),
        tf.keras.layers.Dropout(0.4),
        tf.keras.layers.Dense(1024, activation='relu',
                             kernel_initializer=tf.keras.initializers.glorot_uniform()),
        tf.keras.layers.Dropout(0.4),
        tf.keras.layers.Dense(N_CLASSES, activation='sigmoid',
                             kernel_initializer=tf.keras.initializers.glorot_uniform())
    ])
    
    model.compile(optimizer=tf.keras.optimizers.Adam(0.001),
                 loss='categorical_crossentropy',
                 metrics=['acc'])
    model.summary()
    return model

m4 = FC_model2()

feature_train = ResNet_base.predict(X_train)
feature_val = ResNet_base.predict(X_val)
feature_test = ResNet_base.predict(X_test)

m4.fit(feature_train, y_train, batch_size=256, epochs=10, validation_data=(feature_val, y_val))

높은 loss와 낮은 acc를 보이고 있습니다.

횟수를 높혀서 100 epoch로 학습을 더 진행했을 때, 점점 성능이 향상되고 있었지만, 400회 정도 반복 학습을 했지만 약 50%의 train acc와 57~61%의 val acc를 도달했고, 이 값에 수렴하고 더 이상 성능이 좋아지지 않았습니다.

 

ResNet의 마지막 stage 정도만 재학습을 진행해보도록 하겠습니다.

tf.random.set_seed(7)
ResNet_model = tf.keras.models.Sequential([
    ResNet_base,
    tf.keras.layers.Flatten(),
    tf.keras.layers.Dense(1024, activation='relu',
                        kernel_initializer=tf.keras.initializers.glorot_uniform()),
    tf.keras.layers.Dropout(0.4),
    tf.keras.layers.Dense(N_CLASSES, activation='sigmoid',
                        kernel_initializer=tf.keras.initializers.glorot_uniform())
])
ResNet_model.summary()

ResNet_base.trainable = True

set_trainable = False
for layer in ResNet_base.layers:
    if layer.name == 'conv5_block1_1_conv':
        set_trainable = True
    if set_trainable:
        layer.trainable = True
    else:
        layer.trainable = False

print('훈련되는 가중치의 수:', len(ResNet_model.trainable_weights))

ResNet_model.compile(optimizer=tf.keras.optimizers.Adam(0.0001),
                 loss='categorical_crossentropy',
                 metrics=['acc'])
history = ResNet_model.fit(X_train, y_train, batch_size=256, epochs=10, validation_data=(X_val, y_val))

우선 10 epoch으로 학습을 진행해봅니다.

ResNet_model.evaluate(X_test, y_test)

train acc는 매우 높은데, val acc를 보니... 매우 과적합이 된 것 같습니다. learning rate를 0.00001로 변경하고 50 epoch 정도 더 학습을 했지만, 크게 성능이 향상되지 않고 여전히 overfitting된 상태였습니다.

 

이렇게 다양한 모델로 교통 표지판 인식 모델을 만들어보았는데, 이미지 사이즈가 작다보니, layer가 작은 모델이 더 좋은 결과를 보여주고 있습니다.

댓글