귀퉁이 서재

컴퓨터 비전 - 5. 얼굴 이미지에서 감정 분류(Emotion Classification) 본문

컴퓨터 비전

컴퓨터 비전 - 5. 얼굴 이미지에서 감정 분류(Emotion Classification)

Baek Kyun Shin 2023. 3. 9. 23:44

이번에는 신경망을 활용해 얼굴 이미지에서 감정을 분류하는 모델을 만들어보겠습니다. 사실 감정 분류와 이미지 다중 분류는 다를 바 없습니다. 

코드 링크 : https://github.com/BaekKyunShin/Computer-Vision-Basic/blob/main/Project3-Emotion_Classification/Emotion_Classification.ipynb


아래 코드는 구글 코랩(colab)을 바탕으로 설명합니다.

1. 구글 드라이브 마운트 & 이미지 데이터셋 불러오기

가장 먼저 구글 드라이브를 마운트합니다.

from google.colab import drive
drive.mount('/content/drive')

이어서 감정 분류에 사용할 이미지 데이터를 불러오겠습니다. FER(Face Emotions Recognition)이라는 데이터셋을 사용할 겁니다. FER은 48x48 픽셀로 구성된 흑백 얼굴 이미지로 이루어진 데이터셋입니다. 7가지 감정을 표현한 얼굴들이죠(0=화남, 1=역겨움, 2=두려움, 3=행복함, 4=슬픔, 5=놀람, 6=평범함). 훈련 데이터는 28,709개이고, 테스트 데이터는 3,589개입니다. FER 이미지 데이터셋은 이 링크에서 다운로드할 수 있습니다.

import zipfile

path = '/content/drive/MyDrive/colab/Computer-Vision-Course/Data/Datasets/fer_images.zip'
zip_object = zipfile.ZipFile(file=path, mode='r')
zip_object.extractall('./')
zip_object.close()

FER 데이터셋을 불러왔으니 샘플로 출력해볼까요? Happy 폴더에는 '행복함'을 나타내는 얼굴 이미지가 있습니다. Happy 폴더 안에 있는 임의의 이미지를 출력해보겠습니다.

import tensorflow as tf

tf.keras.preprocessing.image.load_img('/content/fer2013/train/Happy/100.jpg')

48x48 픽셀이라서 이미지가 아주 작네요. 다른 이미지도 하나 출력해보죠.

tf.keras.preprocessing.image.load_img('/content/fer2013/train/Surprise/100.jpg')

다음과 같이 이미지 크기를 출력해보면 (48, 48, 3)임을 알 수 있습니다. 마지막 채널수가 3입니다. 흑백 이미지이지만 채널이 3이네요. RGB값이 모두 같으면 항상 흑백 이미지가 됩니다. 예를 들어 색상이 (100, 100, 100)이면 빨간, 초록, 파란색 값이 같은 비율로 들어가 있어서 흑백 이미지가 됩니다. 따라서 FER 이미지의 RGB 값은 모두 같습니다. 흑백 이미지이니까요.

import numpy as np

img = tf.keras.preprocessing.image.load_img('/content/fer2013/train/Neutral/100.jpg')
np.array(img).shape

2. 훈련, 테스트 데이터셋 만들기

다음으로 훈련용, 테스트용 데이터셋을 만들어보겠습니다. 텐서플로로 CNN 모델을 설계해서 훈련할 겁니다. 이 모델에서 사용할 수 있도록 데이터셋을 구축하는 겁니다. 텐서플로로 데이터셋을 만들기 전에 먼저 이미지 데이터 제너레이터를 만들어야 합니다. 이미지 데이터 제너레이터란 데이터 증강(Data Augmentation)을 적용한 이미지 데이터를 배치로 불러오는 기능을 하는 객체입니다. ImageDataGenerator로 생성을 합니다. 여러 파라미터가 있습니다. 각 파라미터별 특징은 텐서플로 공식 문서를 참고해주세요.

from tensorflow.keras.preprocessing.image import ImageDataGenerator

train_generator = ImageDataGenerator(rotation_range=10,  # Degree range for random rotations
                                     zoom_range=0.2,  # Float or [lower, upper]. Range for random zoom. If a float, [lower, upper] = [1-zoom_range, 1+zoom_range]
                                     horizontal_flip=True,  # Randomly flip inputs horizontally
                                     rescale=1/255)  # Rescaling by 1/255 to normalize

여기서는 이미지 회전, 확대/축소, 수평 대칭, 정규화를 했습니다. 이미지 회전, 확대/축소, 수평 대칭은 데이터 증강 기능이고, 정규화는 데이터의 픽셀값을 0~1 사이로 맞추기 위함입니다.

이미지 데이터 제너레이터를 만들었으면, 데이터셋을 만들 수 있습니다. flow_from_directory() 메서드를 실행하면 됩니다.

train_dataset = train_generator.flow_from_directory(directory='/content/fer2013/train',
                                                    target_size=(48, 48),  # Tuple of integers (height, width), defaults to (256, 256)
                                                    class_mode='categorical',
                                                    batch_size=16,  # Size of the batches of data (default: 32)
                                                    shuffle=True,  # Whether to shuffle the data (default: True) If set to False, sorts the data in alphanumeric order
                                                    seed=10)

directory 파라미터에 전달한 경로에서 데이터를 불러옵니다. 그때 앞서 정의한 데이터 증강을 적용해서 불러옵니다. target_size=(48, 48)로 설정하면 이미지 크기를 (48, 48)로 불러옵니다. FER2013 이미지는 (48, 48) 크기를 갖습니다. 그러므로 (48, 48)을 전달했습니다. 기본값은 (256, 256)입니다. 배치 크기는 16으로 설정했고, 데이터를 섞어서 불러오기 위해 shuffle=True를 전달했습니다. 훈련을 할 땐 데이터를 섞어주는 게 좋고, 테스트할 때는 섞으면 안 됩니다. 테스트 시에는 데이터 순서가 일정해야 예측값과 실제값을 비교할 수 있기 때문이죠.

불러온 훈련 데이터셋의 타깃값을 보려면 classes 속성을 호출하면 됩니다.

train_dataset.classes

   array([0, 0, 0, ..., 6, 6, 6], dtype=int32)

0부터 6까지 있을 텐데, 각 타깃값의 의미가 무엇인지 보려면 class_indices를 호출하면 됩니다.

train_dataset.class_indices

   {'Angry': 0, 'Disgust': 1, 'Fear': 2, 'Happy': 3, 'Neutral': 4, 'Sad': 5, 'Surprise': 6}

각 타깃값별로 데이터 개수가 몇 개인지 한번 살펴보시죠.

np.unique(train_dataset.classes, return_counts=True)

  (array([0, 1, 2, 3, 4, 5, 6], dtype=int32), 
   array([3995,  436, 4097, 7215, 4965, 4830, 3171]))

역겨움을 나타내는 이미지 1의 개수가 436개로 가장 적네요.

다음으로는 테스트 데이터셋을 만들어보겠습니다. 절차는 앞서 설명한 바와 같습니다. 테스트 데이터에는 데이터 증강을 적용하지 않았고, 데이터 순서도 섞지 않았습니다. 배치 크기도 1로 설정했습니다. 테스트할 때는 배치 단위로 하지 않고 하나씩 예측을 할 것이기 때문입니다.

test_generator = ImageDataGenerator(rescale=1/255)

test_dataset = test_generator.flow_from_directory(directory='/content/fer2013/validation',
                                                  target_size=(48, 48),
                                                  class_mode='categorical',
                                                  batch_size=1,
                                                  shuffle=False,
                                                  seed=10)

3. CNN 모델 설계

지금까지 훈련, 테스트 데이터셋을 만들어봤습니다. 이제는 CNN 모델을 설계해보겠습니다. 이미지에서 감정을 분류하는 간단한 모델을 직접 구현해보겠습니다.

비전을 다루려면 딥러닝 기초는 이미 알고 있어야 합니다. 딥러닝 기초는 안다고 가정해, 여기서는 신경망을 설계하는 방법을 자세히 설명하진 않겠습니다.
from tensorflow.keras.models import Sequential
from tensorflow.keras.layers import Dense, Dropout, Conv2D, MaxPooling2D, Flatten, BatchNormalization

num_classes = 7
num_detectors = 32
width, height = 48, 48

network = Sequential()

network.add(Conv2D(filters=num_detectors, kernel_size=3, activation='relu', padding='same', input_shape=(width, height, 3)))
network.add(BatchNormalization())
network.add(Conv2D(filters=num_detectors, kernel_size=3, activation='relu', padding='same'))
network.add(BatchNormalization())
network.add(MaxPooling2D(pool_size=(2, 2)))
network.add(Dropout(0.2))

network.add(Conv2D(2*num_detectors, 3, activation='relu', padding='same'))
network.add(BatchNormalization())
network.add(Conv2D(2*num_detectors, 3, activation='relu', padding='same'))
network.add(BatchNormalization())
network.add(MaxPooling2D(pool_size=(2, 2)))
network.add(Dropout(0.2))

network.add(Conv2D(2*2*num_detectors, 3, activation='relu', padding='same'))
network.add(BatchNormalization())
network.add(Conv2D(2*2*num_detectors, 3, activation='relu', padding='same'))
network.add(BatchNormalization())
network.add(MaxPooling2D(pool_size=(2, 2)))
network.add(Dropout(0.2))

network.add(Conv2D(2*2*2*num_detectors, 3, activation='relu', padding='same'))
network.add(BatchNormalization())
network.add(Conv2D(2*2*2*num_detectors, 3, activation='relu', padding='same'))
network.add(BatchNormalization())
network.add(MaxPooling2D(pool_size=(2, 2)))
network.add(Dropout(0.2))

network.add(Flatten())

network.add(Dense(2*2*num_detectors, activation='relu'))
network.add(BatchNormalization())
network.add(Dropout(0.2))

network.add(Dense(2*num_detectors, activation='relu'))
network.add(BatchNormalization())
network.add(Dropout(0.2))

network.add(Dense(num_classes, activation='softmax'))

network.summary()를 실행하면 우리가 설계한 신경망 구조를 볼 수 있습니다.

4. 모델 훈련

모델을 설계했으니 이제 훈련을 해보죠. 훈련하기 전에 먼저 모델을 컴파일합니다. 옵티마이저, 손실 함수, 평가 지표를 설정하는 작업입니다.

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

컴파일을 마쳤으니 본격적으로 훈련해보겠습니다. 에폭수를 설정해주고, fit() 메서드를 호출하면 됩니다.

epochs = 70

network.fit(train_dataset, epochs=epochs)

이 코드는 시간이 상당히 걸립니다. 구글 코랩 기준, CPU로 돌리면 20시간 가까이 걸립니다. GPU로는 1시간 조금 넘게 걸립니다. 이미지도 많거니와 에폭수도 크기 때문이에요. 결과를 빨리 보려면 에폭을 줄여서 실행해보세요. 물론 에폭을 줄인 만큼 예측 성능은 떨어지겠죠?

  Epoch 1/70 1795/1795 [==============================] - 76s 33ms/step - loss: 1.8536 - accuracy: 0.2625
  Epoch 2/70 1795/1795 [==============================] - 59s 33ms/step - loss: 1.5566 - accuracy: 0.3928

  ...
  Epoch 69/70 1795/1795 [==============================] - 53s 30ms/step - loss: 0.7282 - accuracy: 0.7329
  Epoch 70/70 1795/1795 [==============================] - 54s 30ms/step - loss: 0.7304 - accuracy: 0.7341

오랜 시간이 흐른 뒤, 훈련을 마쳤습니다.

5. 모델 성능 평가

훈련을 마쳤으니 성능을 평가해봐야겠죠. evaluate() 메서드에 테스트 데이터셋을 전달하면 성능 평가 결과를 보여줍니다.

network.evaluate(test_dataset)

  3589/3589 [==============================] - 21s 6ms/step - loss: 1.5031 - accuracy: 0.5784

손실값은 1.5이고 정확도는 57.8%이네요. 정확도를 직접 구할 수도 있습니다. 먼저 테스트 데이터를 활용해 예측을 합니다.

preds = network.predict(test_dataset)
preds

  array([[8.3438432e-01, 5.6078522e-03, 2.8129719e-02, ..., 2.4448015e-02, 9.9659674e-02, 7.7695400e-03],
  ...후략...

7가지 클래스에 대한 확률값들이 출력됩니다. 각 확률값 가운데 가장 큰 값이 최종 예측 클래스입니다. 가장 큰 확률값을 갖는 클래스는 np.argmax() 메서드로 찾을 수 있습니다.

preds = np.argmax(preds, axis=1)
preds

  array([0, 0, 0, ..., 6, 6, 6])

이 예측값과 실제 클래스 값을 비교해보면 되겠죠?

test_dataset.classes

  array([0, 0, 0, ..., 6, 6, 6], dtype=int32)

사이킷런의 accuracy_score() 메서드를 활용해 두 값들 사이의 정확도를 구해보죠.

from sklearn.metrics import accuracy_score

accuracy_score(test_dataset.classes, preds)

  0.5784341042073001

앞서 network.evaluate(test_dataset)으로 구한 정확도 값과 일치하네요.

6. 실제 이미지로 감정 분류해보기

이제 실제 이미지로 감정 분류를 해보겠습니다. 앞서 훈련한 network 모델을 사용해서요. 먼저 이미지를 하나 불러옵니다. 불러온 이미지는 이곳에서 다운로드했습니다.

import cv2
from google.colab.patches import cv2_imshow

image = cv2.imread('/content/drive/MyDrive/colab/Computer-Vision-Course/Data/Images/happy_face2.jpg')

cv2_imshow(image)

CNN 객체 검출기를 사용해볼 겁니다. CNN 객체 검출기는 컴퓨터 비전 - 2. HOG 얼굴 검출 (HOG Face Detection)에서 설명했으니 참고해주세요.

import dlib
face_detector = dlib.cnn_face_detection_model_v1('/content/drive/MyDrive/colab/Computer-Vision-Course/Data/Weights/mmod_human_face_detector.dat')

face_detection = face_detector(image, 1)

검출한 얼굴 이미지만 추출해서 출력해보시죠.

left, top, right, bottom = face_detection[0].rect.left(), face_detection[0].rect.top(), face_detection[0].rect.right(), face_detection[0].rect.bottom()

roi = image[top:bottom, left:right]

cv2_imshow(roi)

얼굴만 잘 추출했네요. 검출한 얼굴 이미지의 크기를 출력해보죠.

roi.shape

  (141, 142, 3)

앞서 모델을 설계할 때, 입력 이미지 크기가 (48, 48)이 되게끔 만들었습니다. 따라서 이 이미지를 모델에 전달하려면 이미지 크기를 (48, 48)로 조정해야 합니다.

# Resize image
roi = cv2.resize(roi, (48, 48))

roi.shape

  (48, 48, 3)

(48, 48)로 잘 조절했습니다. 또한, 훈련할 때 이미지를 정규화했으므로 이 이미지의 픽셀값들도 모두 255로 나눠 정규화해야 합니다.

# Normalize
roi = roi / 255

보통 딥러닝 모델에 데이터를 전달하려면 배치 단위로 전달합니다. 그래서 데이터 형상(shape)이 (배치 크기, 가로 크기, 세로 크기, 채널수)입니다. 우리는 하나의 이미지로만 테스트할 것이니다. 따라서 배치 크기를 1로 만들어야 하죠. 넘파이의 expand_dim() 함수로 배치 크기 1을 더하겠습니다.

roi = np.expand_dims(roi, axis=0)
roi.shape

  (1, 48, 48, 3)

(48, 48, 3)이던 형상이 (1, 48, 48, 3)으로 바뀌었네요. 배치 크기까지 추가됐으니 이제 딥러닝 모델에 넣을 수 있습니다.

pred_probability = network.predict(roi)
pred_probability

  array([[1.6061535e-01, 1.4578884e-04, 1.8617315e-02, 7.9159176e-01,
               2.1857673e-02, 5.3516589e-03, 1.8204855e-03]], dtype=float32)

7가지 클래스에 대한 예측 확률값입니다. 가장 큰 값은 네 번째 값이네요(0부터 인덱스를 매기면 3번째입니다).

pred = np.argmax(pred_probability)
pred

  3

test_dataset.class_indices

  {'Angry': 0, 'Disgust': 1, 'Fear': 2, 'Happy': 3, 'Neutral': 4, 'Sad': 5, 'Surprise': 6}

인덱스 3은 Happy입니다. 행복한 얼굴이라는 뜻이죠. 사진을 보면 웃고 있습니다. '행복함'으로 감정을 제대로 분류했네요.

지금까지 결과를 보면 감정 분류도 이미지 다중 분류와 다를 바 없습니다. 훈련 데이터에는 각 이미지와 대응하는 클래스(타깃값)가 있습니다. 그런 이미지-클래스 쌍들을 딥러닝 모델로 훈련하면 되는 것이죠. 이런 관점에서 이미지 다중 분류와 감정 분류는 같은 원리입니다. 웃는 얼굴, 화난 얼굴, 놀란 얼굴 등의 특징이나 패턴을 딥러닝 모델이 잘 구분할 수 있는 겁니다.


참고 자료

TensorFlow Document - "ImageDataGenerator"

FER2013 Data

Jones Granatyr(Udemy) - "Computer Vision: Master Class"

Comments