(동글(동화 그루) 프로젝트에서 웹캠을 이용해 실시간으로 emotion detection을 하는 기능을 만들기 위한 과정을 정리해봤다.)
1. Colab에서 Facial Emotion Recognition 학습시키기
먼저 CNN으로 표정 데이터셋을 학습시키도록 하자! 학습에 사용한 데이터는 FER2013 데이터셋으로 angry, disgust, fear, happy, sad, surprise, neutral 7가지의 감정으로 라벨링되어있다. 모델 학습은 다음과 같은 순서로 진행되었다.
0) 필요한 라이브러리 가져오기
1) 이미지 전처리(preprocessing)
2) SMOTE 기법을 적용하여 Over-sampling
3) image augmentation
4) 모델 정의
5) 모델 훈련
6) 모델 평가 및 regularization 적용
7) 다시 훈련 및 평가
한 단계씩 코드와 함께 보도록 하자~!~!
0) 필요한 라이브러리 가져오기
사용한 라이브러리들은 다음과 같다.
import pandas as pd
import numpy as np
import tensorflow as tf
import matplotlib.pyplot as plt
import keras
from sklearn.model_selection import train_test_split
from keras.models import Sequential
from keras.layers import Conv2D, MaxPool2D, AveragePooling2D, Input, BatchNormalization, MaxPooling2D, Activation, Flatten, Dense, Dropout
from keras.models import Model
from tensorflow.keras.optimizers import Adam
from sklearn.preprocessing import OneHotEncoder
from sklearn.metrics import classification_report
from imblearn.over_sampling import SMOTE
from tensorflow.keras.preprocessing.image import ImageDataGenerator
from tensorflow.keras.callbacks import EarlyStopping, ModelCheckpoint, ReduceLROnPlateau
from keras.preprocessing import image
import scipy
import os
import cv2
1) 이미지 전처리(preprocessing)
fer2013 이미지들에 대한 라벨값과 픽셀값이 들어있는 fer2013.csv 파일을 이용하였기 때문에, 이 픽셀값을 48x48의 학습 이미지로 전처리해야 한다. 전처리 코드는 다음과 같다.
def preprocess_pixels(pixel_data):
images = []
for i in range(len(pixel_data)):
img = np.fromstring(pixel_data[i], dtype='int', sep=' ')
img = img.reshape(48,48,1)
images.append(img)
X = np.array(images)
return X
※ 실제로는 데이터 over sampling 후 전처리를 하였기 때문에(oversampler가 2이하의 dimension을 요구함) reshape 과정은 따로 빼서 진행했다.
2) SMOTE 기법을 적용하여 Over-sampling
fer2013 데이터셋의 경우 happy 클래스가 7000장대인 것에 반해 disgust 클래스는 400장 대, surprise와 angry는 3000장 대로 데이터셋의 불균형이 존재한다. 데이터셋의 불균형이 존재할 경우, 모델은 적은 수의 클래스(minority) 분포를 제대로 학습하지 못하게 되고, 이는 majority 클래스의 오버피팅되어 어떤 데이터에 대해서도 major 클래스로 분류시키는 문제를 발생시킨다. 따라서 over sampling 기법을 적용하여 데이터셋의 불균형을 줄이도록 하였다.
사용해본 over sampling 기법은 두 가지로, Random oversampling과 SMOTE를 사용하였다. 이 두가지 방식에 대해 간단히 설명하도록 하겠다!
- Random oversampling
기존에 존재하는 소수의 클래스를 단순 복제하여 비율을 맞춰준다. 단순 복제이기 때문에 분포는 변하지 않지만 숫자가 늘어나 더 많은 가중치를 받게할 수 있다. 그러나, 똑같은 데이터를 증식시켜 오버피팅의 위험 역시 존재한다!
from imblearn.over_sampling import RandomOverSampler
oversampler = RandomOverSampler(sampling_strategy='auto')
X_over, Y_over = oversampler.fit_resample(X, label_data)
- SMOTE
가장 유명한 over sampling 기법이다. SMOTE는 임의의 소수 클래스 데이터로부터 인근 소수 클래스 사이에 새로운 데이터를 생성하는 것이다.
그림과 같이 소수 클래스에 해당하는 관측치 X에 대해서 가까운 k개의 이웃을 찾는다. 그 다음 이 X와 k개의 X 사이에 새로운 데이터를 추가하는 방식이다.
from imblearn.over_sampling import SMOTE
smote = SMOTE(random_state=0)
X_over,Y_over = smote.fit_resample(X, label_data)
- over sampling 전 데이터 X.shape
- over sampling 및 전처리 후 데이터 X.shape, Y.shape
3) image augmentation
Data Augmentation은 데이터의 양을 늘리기 위해 원본에 각종 변환을 적용하여 개수를 증강시키는 기법이다. 10도의 rotation, 10%의 horizontal/vertical shifting, horizontal flip을 적용하였다.
X_aug = ImageDataGenerator(
rotation_range = 10,
horizontal_flip = True,
width_shift_range=0.1,
height_shift_range=0.1)
X_aug.fit(X_train)
4) 모델 정의
다음은 사용할 CNN 모델을 구축하는 과정이다. 먼저 ResNet 등 기존 모델을 파인 튜닝하는 방식을 생각하였으나 이론적인 학습이 목표가 아닌, 실제 이 모델을 실행할 웹(or 웹앱)을 통해 실시간으로 on-device running이 가능하도록 하는 것을 우선시해야 했으므로 보다 가벼운 모델로 구현하기로 결정하였다.
정의한 모델은 다음과 같다.
model = Sequential()
model.add(Conv2D(32, kernel_size=(3,3), strides=(1,1), padding='same', input_shape=(48,48,1)))
model.add(BatchNormalization(axis=3))
model.add(Activation('relu'))
model.add(MaxPooling2D((2,2)))
model.add(Conv2D(64, (3,3), strides=(1,1), padding = 'same', kernel_regularizer = tf.keras.regularizers.l2(0.0005)))
model.add(BatchNormalization(axis=3))
model.add(Activation('relu'))
model.add(MaxPooling2D((2,2)))
model.add(Conv2D(64, (3,3), strides=(1,1), padding = 'same', kernel_regularizer = tf.keras.regularizers.l2(0.0005)))
model.add(BatchNormalization(axis=3))
model.add(Activation('relu'))
model.add(MaxPooling2D((2,2)))
model.add(Conv2D(128, (3,3), strides=(1,1), padding = 'same', kernel_regularizer = tf.keras.regularizers.l2(0.0005)))
model.add(BatchNormalization(axis=3))
model.add(Activation('relu'))
model.add(MaxPooling2D((2,2)))
model.add(Conv2D(128, (3,3), strides=(1,1), padding = 'same', kernel_regularizer = tf.keras.regularizers.l2(0.0005)))
model.add(BatchNormalization(axis=3))
model.add(Activation('relu'))
model.add(MaxPooling2D((2,2)))
model.add(Flatten())
model.add(Dense(256))
model.add(BatchNormalization())
model.add(Activation('relu'))
model.add(Dropout(0.25))
model.add(Dense(7, activation='softmax'))
adam = Adam(lr=0.0001)
model.compile(optimizer=adam, loss='categorical_crossentropy', metrics=['accuracy'])
5) 모델 훈련
이제 모델을 훈련시켜보자~!
- 그 전에 먼저 X, Y 를 8:2로 train, test 데이터로 나누고, 다시 X_train을 8:2로 train, validation 데이터로 나누어주었다.
X_train, X_test, Y_train, Y_test = train_test_split(X, Y, stratify=Y, test_size = 0.2, random_state=45)
X_train, X_val, Y_train, Y_val = train_test_split(X_train, Y_train, stratify=Y_train, test_size=0.2, random_state=45)
※ validation set은 학습이 이미 완료된 모델을 검증하기 위한 dataset이고, test set은 학습과 검증이 완료된 모델의 성능을 평가하기위한 dataset이다. test set을 따로 두는 이유는 validation set에 맞춰 hyper parameter를 바꾸며 학습을 진행하기 때문에 이 모델이 validation data에 bias되기 때문이다. 따라서 bias되지 않은 test set으로 최종 모델 평가를 해주어야 한다!
- 훈련 전, 이미지 감정 라벨을 one-hot encoding 해야한다.
from sklearn.preprocessing import OneHotEncoder
encoder = OneHotEncoder()
encoder.fit(Y_train)
Y_train = encoder.transform(Y_train).toarray()
Y_test = encoder.transform(Y_test).toarray()
Y_val = encoder.transform(Y_val).toarray()
- 이제 진짜 훈련!
history = model.fit(X_aug.flow(X_train, Y_train), epochs=epochs, validation_data=(X_val, Y_val))
6) 모델 평가 및 수정 반복
모델에 대한 평가 결과가 나오면 hyper parameter를 변경해보고, 오버피팅이 발생하면 여러 regularization 기법들을 적용해가면서 훈련을 반복한다.
또한 몇 가지 callback 함수들을 적용하여 성능을 높이고자 하였는데, 간단하게 소개하도록 하겠다.
es = EarlyStopping(patience=60)
mc = ModelCheckpoint("filepath", monitor='val_loss', save_best_only=True)
rlr = ReduceLROnPlateau(factor=0.1, patience=5)
- EarlyStopping: 모델을 더 이상 학습을 못 할 경우(loss, metric등의 개선이 없어서), 학습 도중 미리 학습을 종료시킨다.
- ModelCheckpoint: 모델 저장시 사용된다. 예를 들어, 위의 경우 모니터되고 있는 값인 val_loss를 기준으로 가장 좋은 값의 모델을 저장해준다.
- ReduceLROnPlateau: 모델의 개선이 없을 경우, Learning Rate를 조절해 모델의 개선을 유도한다.
콜백함수를 사용하려면 모델 훈련 시 추가해주면 된다.
history = model.fit(X_aug.flow(X_train, Y_train), epochs=epochs, validation_data=(X_val, Y_val), callbacks=[es, mc, rlr])
모델의 복잡도도 여러번 바꿔봐서 실행 epochs 차이가 많이 난다.. 최종적으로는 약 80%의 test accuracy를 보였다.
2. 웹캠으로 실시간 얼굴 감정 인식 with opencv (django 사용)
이제 해당 모델을 이용해서 웹캠을 사용한 실시간 감정 인식을 구현하도록 하자. 웹 브라우저 상에 사용자의 얼굴이 실시간으로 보여 감지되도록 opencv를 이용하였다.
model = {model}
emotion_classifier = {model_weights}
face_detection = {haarcascade_frontalface_default.xml}
label_dict = {0: 'Angry', 1: 'Disgust', 2: 'Fear', 3: 'Happiness', 4: 'Sad', 5: 'Surprise', 6: 'Neutral'}
def index(request):
context = {}
return render(request, "index.html", context)
class EmotionDetect(object):
def __init__(self):
self.video = cv2.VideoCapture(0, cv2.CAP_DSHOW)
def __del__(self):
self.video.release()
cv2.destroyAllWindows()
def get_frame(self):
_, cap_image = self.video.read()
cap_image = cv2.flip(cap_image, 1)
cap_img_gray = cv2.cvtColor(cap_image, cv2.COLOR_BGR2GRAY)
faces = face_detection.detectMultiScale(cap_img_gray, 1.3, 5)
for (x, y, w, h) in faces:
cv2.rectangle(cap_image, (x, y), (x + w, y + h), (255, 0, 0), 2)
roi_gray = cap_img_gray[y:y + h, x:x + w]
roi_gray = cv2.resize(roi_gray, (48, 48))
img_pixels = image.img_to_array(roi_gray)
img_pixels = np.expand_dims(img_pixels, axis=0)
predictions = model.predict(img_pixels)
emotion_label = np.argmax(predictions)
cv2.putText(cap_image, emotion_prediction, (int(x), int(y)), cv2.FONT_HERSHEY_SIMPLEX, 0.75, (0, 0, 0),
1)
cap_image = cv2.resize(cap_image, (1000, 700))
ret, jpeg = cv2.imencode('.jpg', cap_image)
return jpeg.tobytes()
def gen(camera):
while True:
frame = camera.get_frame()
yield (b'--frame\r\n'
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n\r\n')
def detect(request):
return StreamingHttpResponse(gen(EmotionDetect()),
content_type='multipart/x-mixed-replace; boundary=frame')
기본 코드는 다음과 같다.
- cv2.VideoCapture(0, cv2.CAP_DSHOW): 사용자의 기기에 연결된 카메라를 킨다.
- cap_img_gray = cv2.cvtColor(cap_image, cv2.COLOR_BGR2GRAY): 모델이 흑백 이미지를 입력으로 받으므로 흑백 처리를 해준다.
- roi_gray = cv2.resize(roi_gray, (48, 48)): 48x48로 resize 해준다.
- img_pixels = image.img_to_array(roi_gray): 입력받은 이미지에 대한 픽셀값 변환 후 모델에 input으로 들어간다.
Django의 StreamingHttpResponse를 이용하면 프레임 별로 모델에 입력된 후 opencv를 통해 emotion 및 face box를 표시 후 반환해준다.
<body>
<h1>video streaming for emotion detection</h1>
{% block body %}
<img src="http://127.0.0.1:8000/detect" />
{% endblock %}
</body>
다음과 같이 lcoalhost로 요청을 보내면 img src에 실시간 detection이 보이게 된다!
해당 코드를 기반으로 특정 시간동안 가장 많이 집계된 감정을 반환해주도록 하여 웹에 사용할 수 있도록 배포를 진행하였으나... 실제로 배포를 진행하고 나면 !_src.empty() in func tion 'cv::cvtColor' 해당 에러가 발생하며 사용자의 모습을 실시간으로 볼 수 없다. 이 오류는 opencv 오류로 감지할 소스가 없다는 것이고, 웹캠이 켜지지 않았으므로 당연히 발생한다.
음..네...사용자의 웹캠을 키는데 사용되었던 cv2.VideoCapture(0, cv2.CAP_DSHOW) 의 경우 실제 기기에 연결된 웹캠을 켜주는데, 배포를 진행하게 되면 해당 서버에 연결된 카메라가 없는 이상 아무리 이 코드를 호출해봤자 사용자의 카메라는 켜지지 않는다. 따라서 localhost에서만 동작하고, 실제로 배포 서버에서도 동작하게 하려면 기기를 연결시켜 주어야 한다.
그러나 서비스 특성 상 특정 기기에만 연결시켜 동작하게 하는 것은 의미가 없으므로 웹캠에 접근하기 위해 프론트단에서 AI를 이용할 수 있게 하는 방법을 새로 찾았다.
3. 웹캠으로 실시간 얼굴 감정 인식 with tensorflow.js
tensorflow.js를 이용하면 프론트 단에서 https(보안상의 문제로 http에서는 웹캠이 켜지지 않는다.) 통신 시, 웹캠을 켜서 detection을 하도록 하는 것이 가능하다.
1) 먼저 hdf5로 저장된 keras 모델을 tensorflow.js에서 사용하기 위해 변환해주어야 한다. tensorflowjs 설치 후 다음 코드를 터미널에 입력한다.
tensorflowjs_converter --input_format=keras /src/model.hdf5 /result/model
src/model.hdf5는 변환할 keras 모델이고 실행하면 result 폴더 아래 model.json 파일이 생긴다. 이 모델을 tensorflow.js에서 사용할 수 있다!
2) javascript 주요 코드를 살펴보자.
function enableCam() {
control = true;
const constraints = {
audio: false,
video: { width: 440, height: 330 },
};
navigator.mediaDevices.getUserMedia(constraints).then(function(stream) {
video.srcObject = stream;
video.addEventListener('loadeddata', predictWebcam());
cameraaccess = true;
}).catch(errorCallback)
}
function getUserMediaSupported() {
return (navigator.mediaDevices && navigator.mediaDevices.getUserMedia);
}
if (getUserMediaSupported()) {
blazeface.load().then(function (loadedModel) {
model = loadedModel;
});
tf.loadLayersModel('model/model.json', false).then(function (loadedModel) {
model_emotion = loadedModel;
});
enableCam();
} else {
console.warn('getUserMedia() is not supported by your browser');
instructionText.innerHTML = "getUserMedia() is not supported by your browser"
}
function predictWebcam() {
cam_ctx.drawImage(video, 0, 0, width, height);
const frame =cam_ctx.getImageData(0, 0, width, height);
model.estimateFaces(frame).then(function (predictions) {
if(predictions.length === 1) {
landmark = predictions[0]['landmarks'];
nosex = landmark[2][0];
nosey = landmark[2][1];
right = landmark[4][0];
left = landmark[5][0];
length = (left-right)/2 + 5;
//이미지 크롭
const frame2 = cam_ctx.getImageData(nosex - length, nosey-length, 2*length, 2*length);
//Image is converted to tensor - resize, dimension 추가. [1, 48, 48, 1] 로 바꿔서 모델로 전달.
let image_tensor = tf.browser.fromPixels(frame2).resizeBilinear([48, 48]).mean(2).toFloat().expandDims(0).expandDims(-1);
const result = model_emotion.predict(image_tensor);
const predictedValue = result.arraySync();
document.getElementById("angry").style.width = 100*predictedValue['0'][0]+"%";
document.getElementById("disgust").style.width = 100*predictedValue['0'][1]+"%";
document.getElementById("fear").style.width = 100*predictedValue['0'][2]+"%";
document.getElementById("happy").style.width = 100*predictedValue['0'][3]+"%";
document.getElementById("sad").style.width = 100*predictedValue['0'][4]+"%";
document.getElementById("surprise").style.width = 100*predictedValue['0'][5]+"%";
document.getElementById("neutral").style.width = 100*predictedValue['0'][6]+"%";
}
if( control ){
window.requestAnimationFrame(predictWebcam);
}
});
}
- enableCam(): navigator.mediaDevices.getUserMedia(constraints)로 사용자의 웹캠을 켜준다. 앞서 언급했듯이 보안 상의 이유로 localhost 또는 https 통신 환경에서만 웹캠에 접근 가능하다.
- 모델을 로드한다. blazeface는 제공되는 face detector를 가져와 사용한 것이고, model.json은 앞서 변환했던 모델을 이용한다.
- predictWebcam(): 모델에 input 이미지를 넣고 predict를 진행한 후 결과를 표시해준다.
let image_tensor = tf.browser.fromPixels(frame2).resizeBilinear([48, 48]).mean(2).toFloat().expandDims(0).expandDims(-1);
모델에 이미지 input을 넣기 위한 처리가 필요하다. 48x48로 resize를 해주고 dimension을 추가해 input 변환 후 전달한다.
다음과 같이 실시간 detection이 되는 것을 확인할 수 있다!
'& other stories > Study' 카테고리의 다른 글
[NLP] TextRank 이용해 핵심 키워드 추출하기 (0) | 2021.11.23 |
---|---|
[Docker] Docker와 Github Actions 이해하기 (0) | 2021.10.03 |
[JS/Ajax] Ajax를 이용해서 새로고침 없이 페이지 불러오기 (2) | 2021.08.13 |
[JS/DOM] DOM API를 활용한 DOM node 조작하기 (0) | 2021.08.12 |