Intro
내가 했던 프로젝트 중 제일 길었던... 캡스톤 디자인 프로젝트가 끝나간다.. 드디어..! 😇
작년 2학기에 시작하고, 대부분 방학 중에 많이 개발을 했는데 웹 개발을 대강 마무리한 후부터는 얼굴 인식 모델을 fine-tuning하는 데 시간을 많이 썼던 것 같다. 나는 fine-tuning에 필요한 데이터를 수집하고 전처리하는 과정을 주로 맡았는데 fine-tuning을 맡은 친구가 고생을 많이했다..
학교에서 GPU 지원을 방학중에 해주거나 작년 2학기때부터 해줬으면 참 좋았을 텐데..
우리 팀 프로젝트의 주제는 RPA와 AI를 이용한 유사 얼굴 탐색 서비스 (Find your Face, Fedi) 이다. 주제에 대한 설명은 이전 게시물에 더 자세히 적어놨으니 궁금하신 분들은 참고하시길.. + 이번에 2022 구글 솔루션 챌린지(GDSC)에서 상위 50팀 안에 들기도 했다😆
우리는 VGGFace2 데이터셋으로 pretrain 된 Facenet 모델을 사용하기로 결정했는데, 해당 모델이 동양인 얼굴에 대해서는 서양인 얼굴만큼의 정확도가 나오지 않는 문제점이 있었다. 따라서 우리는 자체적으로 데이터를 수집하여 동양인, 그 중에서도 우리 서비스의 타겟인 동양인 여성 (주로 2-30대) 에 대한 얼굴 인식의 정확도를 높이기 위한 fine-tuning을 진행하기로 하였다.
내가 했던 방식은 크게 두 가지 Step으로 나뉜다.
1. 데이터 수집
2. 전처리
1. 데이터 수집 (by Selenium)
처음에는 기존에 제공되는 데이터를 사용해보고자 노력했는데, 한국인 안면 이미지는 한 사람의 얼굴을 그냥 다양한 각도에서 찍은 사진이라 우리의 목적과는 적합하지 않은 데이터셋이었고, asian-celeb-dataset 이라는 파일을 찾았으나.. 바이두로도 토렌트로도 다운이 끝까지 되지 않는 문제 + 용량이 너무 커서 감당할 수 없는 문제 때문에 포기하였다.
그래서 결국 직접 크롤링을 진행하기로 결정했다!
크롤러를 개발하는 데는 흔히 많이 쓰는 Selenium을 사용하였다.
일단 처음에는 구글에서 이미지를 검색해서 다운받는 크롤러를 개발하였는데, 코드는 다음과 같다.
from selenium import webdriver
from selenium.webdriver.common.keys import Keys
import time
import urllib.request
import os
import pandas as pd
## 96장 정도에서 중복생김. 스크롤을 끝까지 내리지 않고 이미 수집했던 사진을 다시 수집함. -> SCROLL_PAUSE_TIME 조정해서 해결
def createDirectory(directory):
try:
if not os.path.exists(directory):
os.makedirs(directory)
except OSError:
print("Error: Failed to create the directory.")
def crawling_img(name):
driver = webdriver.Chrome('C:\chromedriver.exe')
driver.get("https://www.google.co.kr/imghp?hl=ko&tab=wi&authuser=0&ogbl")
elem = driver.find_element_by_name("q")
elem.send_keys(name)
elem.send_keys(Keys.RETURN)
# 도구 > 이미지 크기 > 큼 : 조금이라도 품질이 좋은 이미지를 얻고자.. 필요없는 사람은 생략해도 좋다
driver.find_element_by_xpath('//*[@id="yDmH0d"]/div[2]/c-wiz/div[1]/div/div[1]/div[2]/div[2]/div/div').click()
time.sleep(1)
driver.find_element_by_xpath('//*[@id="yDmH0d"]/div[2]/c-wiz/div[2]/div[2]/c-wiz[1]/div/div/div[1]/div/div[1]/div/div[1]').click()
time.sleep(1)
driver.find_element_by_xpath('//*[@id="yDmH0d"]/div[2]/c-wiz/div[2]/div[2]/c-wiz[1]/div/div/div[3]/div/a[2]').click()
time.sleep(1)
# 스크롤을 끝까지 내리지 않고 이미 수집했던 사진을 다시 수집하는 문제가 발생한다면 SCROLL_PAUSE_TIME을 조정하면 된다.
SCROLL_PAUSE_TIME = 2
# Get scroll height
last_height = driver.execute_script("return document.body.scrollHeight") # 브라우저의 높이를 자바스크립트로 찾음
while True:
# Scroll down to bottom
driver.execute_script("window.scrollTo(0, document.body.scrollHeight);") # 브라우저 끝까지 스크롤을 내림
# Wait to load page
time.sleep(SCROLL_PAUSE_TIME)
# Calculate new scroll height and compare with last scroll height
new_height = driver.execute_script("return document.body.scrollHeight")
if new_height == last_height:
try:
driver.find_element_by_css_selector(".mye4qd").click()
except:
break
last_height = new_height
imgs = driver.find_elements_by_css_selector(".rg_i.Q4LuWd")
dir = "C:/Users/SAMSUNG/Desktop/저장할 위치" + "/" + name
createDirectory(dir) #폴더 생성
count = 1
for img in imgs:
try:
img.click()
time.sleep(2)
imgUrl = driver.find_element_by_xpath(
'//*[@id="Sva75c"]/div/div/div[3]/div[2]/c-wiz/div/div[1]/div[1]/div[2]/div/a/img').get_attribute(
"src")
path = "C:\\Users\\SAMSUNG\\Desktop\\저장할 위치" + name + "\\" # 위에서 만든 폴더에 저장
urllib.request.urlretrieve(imgUrl, path + name + str(count) + ".jpg")
count = count + 1
print(count) #Debug
if count >= 150: # 몇 장을 저장할 지 정할 수 있다.
break
except:
pass
driver.close()
# 검색하고자 하는 인물의 이름을 엑셀에 저장해서 불러왔다.
df = pd.read_excel('celeb_name.xlsx', header=None)
idols = list(df[1])
for idol in idols[48:]:
crawling_img(idol)
아마 위의 코드를 그대로 실행한다면 에러가 날 수도 있다. 일단 chromedriver를 자신의 chrome 버전에 맞게 다운을 해줘야 하고, 셀레니움을 이용한 크롤러에서 제일 오류가 많이 나는 부분이 element를 찾는 부분인데.. 이 부분은 직접 돌려보면서 제대로 인식을 못한다 싶으면 css나 xpath 등등.. 다양한 걸로 바꾸면서 제일 잘 찾는 것으로 하는 게 좋다. xpath 주소 같은 경우는 시간이 지나면 바뀔 수도 있기 때문에 그때그때 잘 확인해서 바꿔줘야 한다.
위에서 이미지 크기를 '큼'으로 선택하는 부분은 후에 내가 추가한 것인데, 그렇게 하면 조금 더 품질이 좋은 데이터를 애초에 크롤링할 수 있더라. (처음부터 알았으면 좋았을 걸..)
나는 우선 엑셀 파일에 검색하고자 하는 연예인의 이름을 쭉 적은 후에 해당 엑셀 파일을 불러와서 크롤러를 돌렸다. 연예인 이름을 500명가까이 생각해내는 것도 정말 힘든 작업이었다..😇
처음에는 이런 방식으로 100명에 대해 150장씩 크롤러를 돌려서 이미지를 얻었는데, 뒤에서 전처리 과정을 설명하겠지만 전처리를 거친 후에 살펴보니 이게 웬걸.. 쓸만한 데이터가 한 사람당 평균 30장 내외였다. 나는 분명 150장을 처음에 크롤링했는데!! 심지어 크롤링에 걸리는 시간도 길어서 하루종일 그것도 며칠씩 노트북을 켜놓아야 했다.. 여기서 얻은 교훈은 >>처음 크롤링 할 때 최대한 많이 다운받는 것<< 이다.. 어차피 그거의 반의 반의 반의 반도 안 남기 때문... 하하..
그래서 데이터가 부족해서 더 다운받아야 하는데 또다시 구글에서 크롤링을 하자니 중복되는 데이터가 너무 많을 것 같아 안되겠고.. 그래서 다른 검색 엔진으로 눈을 돌렸다. bing 에서 크롤러를 동일한 방식으로 개발하여 돌려보려 했으나 xpath를 인식하는 부분이 정말 죽어도 안되길래,, 깃허브에 오픈소스로 올라온 크롤러도 찾아서 해봤는데 한국어로 검색해서 그런건지 무슨 말도 안되는 검색결과가 나와서 결국 bing은 포기했다.
다음으로 시도해 본 것은 네이버 크롤러! 여기는 그래도 제대로 잘 작동이 됐다.
코드는 위의 코드에서 url과 xpath 같은 것들을 조정하면 된다. 개인적으로는 네이버에서 이미지 크기를 '큼' 으로 선택해서 검색했을 때 나오는 결과가 제일 품질이 좋아서 마음에 들었는데 스크롤이 한 6번 정도 내려가면 더 이상 결과가 나오지 않아서 아쉬웠다ㅠ
이렇게 해서 총 여성 celeb 510명 + 남성 celeb 90명 = 600명의 얼굴에 대한 크롤링을 하였다. 시행착오를 겪으면서 크롤링을 진행해서 정확히 몇장씩 크롤링했는지는 확실치 않지만, 아마도 적어도 500장씩은 했던 것 같다...
2. 전처리
하지만 중요한 건 데이터 수집이 아니었다.. 전처리였다... 😇
전처리라 쓰고 노가다라 읽어야 하는 바로 그것.. 어디선가 기계학습은 결국 '노가다'이다. 라는 말을 본 적이 있는데 이번에 그것을 뼈저리게 실감했다. 이렇게 적은 데이터셋도 구축하는 게 힘든데 엄청 큰 데이터셋은 얼마나 많은 인력이 들어가야 하는 걸까..
수집한 데이터에서 내가 해야할 일은 크게 두 가지 프로세스로 나눌 수 있었다.
1. 얼굴이 있는 사진인지 판별한 후에 얼굴 부분만 crop + 눈 위치를 계산해서 수평 맞추기 + 동일한 크기로 resize
2. 내가 검색한 연예인 얼굴이 맞는 지, 품질이 너무 떨어지지는 않는지, 화장이 너무 세서 육안으로도 구별이 불가한 지 등등 판단하여 고르기 작업
그리고 예상 가능하듯이 2번 작업이 정말.. 과장이 아니라 정말 속이 울렁거릴 정도로 힘들었다...ㅋㅋㅋ 하루종일 연예인 얼굴만 보고 있으니 현타도 오고.. 이렇게 해도 놓친 부분이 있어서 다시 봐야하고.. 개인적으로 사람 얼굴을 그렇게 잘 구별하는 편은 아니라 속도도 안 붙고.. 와중에 중복된 사진도 체크해야 되고.. ㅠㅠㅠㅠ
일단 1번 프로세스는 MTCNN 모델을 사용했다. 그 중에서도 속도 측면에서 개선한 fast MTCNN 모델을 찾아서 사용했고, 코드는 DeepFace library의 코드를 참고했다.
https://github.com/timesler/facenet-pytorch
https://github.com/serengil/deepface
from facenet_pytorch import MTCNN
from facenet_pytorch.models.utils.detect_face import extract_face
import cv2
import os
from PIL import Image
from matplotlib import pyplot as plt
import numpy as np
import math
import torch
import tensorflow as tf
import base64
import requests
from tensorflow.keras.preprocessing import image # tensorflow ver: 2.xx 일때 사용
#### 디렉토리 만들기 위해 추가한 코드.
def createDirectory(directory):
try:
if not os.path.exists(directory):
os.makedirs(directory)
except OSError:
print("Error: Failed to create the directory.")
def loadBase64Img(uri):
encoded_data = uri.split(',')[1]
nparr = np.fromstring(base64.b64decode(encoded_data), np.uint8)
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
return img
#img might be path, base64 or numpy array. Convert it to numpy whatever it is.
def load_image(img):
exact_image = False; base64_img = False; url_img = False
if type(img).__module__ == np.__name__:
exact_image = True
elif len(img) > 11 and img[0:11] == "data:image/":
base64_img = True
elif len(img) > 11 and img.startswith("http"):
url_img = True
#---------------------------
if base64_img == True:
img = loadBase64Img(img)
elif url_img:
img = np.array(Image.open(requests.get(img, stream=True).raw))
elif exact_image != True: #image path passed as input
if os.path.isfile(img) != True:
raise ValueError("Confirm that ",img," exists")
# img = cv2.imread(img) # 한글 경로 오류 문제...
path = np.fromfile(img, np.uint8)
img = cv2.imdecode(path, cv2.IMREAD_UNCHANGED)
img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) ## 얘 때문이었어!!!!!!
return img
# box 값에 따라 얼굴 이미지를 crop
def crop_image(img, box):
img = img[int(box[1]):int(box[3]), int(box[0]):int(box[2])]
return img
def findEuclideanDistance(source_representation, test_representation):
if type(source_representation) == list:
source_representation = np.array(source_representation)
if type(test_representation) == list:
test_representation = np.array(test_representation)
euclidean_distance = source_representation - test_representation
euclidean_distance = np.sum(np.multiply(euclidean_distance, euclidean_distance))
euclidean_distance = np.sqrt(euclidean_distance)
return euclidean_distance
#-------------------------------------------------------------------------------------------
def alignment_procedure(img, left_eye, right_eye):
#this function aligns given face in img based on left and right eye coordinates
left_eye_x, left_eye_y = left_eye
right_eye_x, right_eye_y = right_eye
#-----------------------
#find rotation direction
if left_eye_y > right_eye_y:
point_3rd = (right_eye_x, left_eye_y)
direction = -1 #rotate same direction to clock
else:
point_3rd = (left_eye_x, right_eye_y)
direction = 1 #rotate inverse direction of clock
#-----------------------
#find length of triangle edges
a = findEuclideanDistance(np.array(left_eye), np.array(point_3rd))
b = findEuclideanDistance(np.array(right_eye), np.array(point_3rd))
c = findEuclideanDistance(np.array(right_eye), np.array(left_eye))
#-----------------------
#apply cosine rule
if b != 0 and c != 0: #this multiplication causes division by zero in cos_a calculation
cos_a = (b*b + c*c - a*a)/(2*b*c)
angle = np.arccos(cos_a) #angle in radian
angle = (angle * 180) / math.pi #radian to degree
#-----------------------
#rotate base image
if direction == -1:
angle = 90 - angle
img = Image.fromarray(img) # 오류
img = np.array(img.rotate(direction * angle))
#-----------------------
return img #return img anyway
def resize_image(img, target_size=(160, 160)):
if img.shape[0] > 0 and img.shape[1] > 0:
factor_0 = target_size[0] / img.shape[0]
factor_1 = target_size[1] / img.shape[1]
factor = min(factor_0, factor_1)
dsize = (int(img.shape[1] * factor), int(img.shape[0] * factor))
img = cv2.resize(img, dsize)
# Then pad the other side to the target size by adding black pixels
diff_0 = target_size[0] - img.shape[0]
diff_1 = target_size[1] - img.shape[1]
# Put the base image in the middle of the padded image
img = np.pad(img, ((diff_0 // 2, diff_0 - diff_0 // 2), (diff_1 // 2, diff_1 - diff_1 // 2), (0, 0)), 'constant')
#double check: if target image is not still the same size with target.
if img.shape[0:2] != target_size:
img = cv2.resize(img, target_size)
#normalizing the image pixels
img_pixels = image.img_to_array(img) #what this line doing? must?
img_pixels = np.expand_dims(img_pixels, axis = 0)
img_pixels /= 255 #normalize input in [0, 1]
return img_pixels
def get_size(img):
if isinstance(img, (np.ndarray, torch.Tensor)):
return img.shape[1::-1]
else:
return img.size
## This function applies pre-processing stages of a face recognition pipeline including detection and alignment
# Parameters:
# img_path: exact image path, numpy array or base64 encoded image
# target_size: 리턴 사이즈
# mtcnn : mtcnn 객체
# Returns:
# deteced and aligned face in numpy format
def detect_face(img_path, mtcnn, margin = 15, target_size = (160, 160)):
img = load_image(img_path)
boxes, probs, landmarks = mtcnn.detect(img, landmarks=True)
if (boxes is None): #얼굴을 탐지하지 못한 경우 원본 이미지 반환
return img
box = boxes[0]
image_size = target_size[0] #160
margin = [
margin * (box[2] - box[0]) / (image_size - margin),
margin * (box[3] - box[1]) / (image_size - margin),
]
raw_image_size = get_size(img)
box = [
int(max(box[0] - margin[0] / 2, 0)),
int(max(box[1] - margin[1] / 2, 0)),
int(min(box[2] + margin[0] / 2, raw_image_size[0])),
int(min(box[3] + margin[1] / 2, raw_image_size[1])),
]
face = crop_image(img, box)
left_eye = landmarks[0][0]
right_eye = landmarks[0][1]
face = alignment_procedure(face, left_eye, right_eye)
face = resize_image(face, target_size) #returns (1, 160, 160, 3)
face_img = face[0]
return face_img
import glob
import pandas as pd
df = pd.read_excel('celeb_name.xlsx', header=None)
img_list = list(df[6])
mtcnn = MTCNN(post_process=False)
def start(img_list, p):
for img in img_list:
print(img)
dir = p + img
createDirectory(dir)
path_list = glob.glob('이미지 위치' + img + '/*')
cnt = 1
for path in path_list:
try:
face = detect_face(path, mtcnn, target_size=(250, 250))
plt.imsave(p + img + '/'+img+'_nn'+str(cnt)+'.jpg', face)
cnt += 1
print(cnt)
except:
continue
start(img_list, '이미지 위치')
위의 코드는 내 로컬 컴퓨터에 크롤링해서 저장한 이미지를 읽어와서 얼굴이 있는지 확인 후 있으면 해당 얼굴을 크롭+alignment+resize를 한 후에 다시 저장하는 코드이다. 이렇게 모든 이미지에 대한 전처리를 거친다.
그래도 이 과정은 코드만 돌려놓으면 되기에 큰 부담은 없다. (시간은 조금 걸리지만 크롤링 할 때보다는 훨씬 적게 걸린다.)
이제 문제는 2번인데.. 사실 이 부분은 뭐라 설명할 게 없다. 말 그대로 노가다..이다. 위의 결과로 저장한 이미지들을 한장 한장 살펴보면서 이건 아닌데? 싶은 이미지는 삭제한다. 이렇게 적어 놓으니 정말 쉬워보이지만 제일 어려웠다..ㅠㅠ
그 후에는 마지막으로 fine-tuning에 적합한 데이터 형태로 맞춰주는 작업을 했다.
그냥 이름 형식을 맞춰주는 작업이기에 따로 코드는 첨부하지 않도록 하겠다.
이렇게 해서 총 300명에 대해서는 약 150장 정도를, 나머지 300명은 50장~100장 정도의 데이터셋을 수집했고 현재 fine-tuning에는 여성 연예인 데이터셋만 사용하고 있어서 약 55,000장 정도의 데이터셋을 사용하고 있다.
사실 시간 여유가 좀 더 된다면 더 많은 데이터를 수집하고 싶었는데.. 아쉬운 마음이 크다. 그래도 이렇게 직접 모델 학습용 데이터를 수집하고 전처리하는 과정을 경험해보니 모델 학습이란 게 모델 그 자체보다 데이터가 더 중요한 것 같다는 생각도 들고.. 참 어렵고 인내심이 많이 필요한 작업이라는 걸 몸소 느끼게 되었다.. ^^
그래도 좋은 결과가 나왔으면 좋겠다. 졸프 끝까지 화이팅 우리 팀원들 화이팅🔥🔥
'프로젝트 > 캡스톤 디자인 프로젝트' 카테고리의 다른 글
스프링부트+JPA (0) | 2022.01.10 |
---|---|
비디오에서 frame을 추출해서 얼굴 인식 모델에 넣어보자 (1) | 2021.11.20 |