1. Chapter 8 - 얼굴 이미지
Chapter 8에서는 GAN을 훈련하여 사람의 얼굴을 생성하는 Task를 수행한다.
Chapter 7에서 수행한 MNIST(사람의 손글씨) 단색 이미지를 생성하는 것보다 더 어려운 Task인 이유는 다음과 같다:
- Full-Color Image (Channel 개수 = 3)를 훈련해야 하고, 생성해야 한다.
- 사진의 훈련 데이터셋을 이용해 조금 더 다양하고 그럴듯한 결과를 생성해야 한다.
1.1) Color Image
Image Data
- Gray Scale (흑백 사진): $H \times W \times 1$
- Channel이 1개만 존재
- Channel이 없다고 생각하면 안됨: $H \times W$ 가 아니라 $H \times W \times 1$로 Channel까지 반드시 고려해야 함
- Color Image (칼러 사진): $H \times W \times 3$
- Channel이 R, G, B로 3개 존재
1.2) CelebA Dataset
GAN을 훈련하는 과정에서 가장 큰 문제는 GAN을 훈련하는데 필요한 충분한 양의 이미지를 확보해야 한다는 것이다.
사실 사람 이미지 10 ~ 100개만으로 GAN을 훈련할 수는 없다.
그렇기 때문에 202, 599개의 유명인 얼굴 이미지가 들어있는 데이터셋인 CelebA 데이터셋을 사용하자.
눈과 입의 위치가 비슷한 좌표에 위치하도록 어느 정도 조정된 이미지도 제공되는데, 이를 사용할 것이다.
해당 데이터셋은 비상업적인 연구와 교육 용도로 사용해야 한다.
1.3) 계층적 데이터 형식 (HDF)
CelebA 데이터셋에는 수 천개의 이미지가 JEPG 파일 형태로 저장되어 있다.
이제 이 파일을 폴더에 풀고, GAN 코드가 훈련 과정에서 자동적으로 이미지를 열고 닫도록 설계할 생각이다.
순차적으로 이미지를 열고 닫아도 동작은 하지만 효율적이지는 않다.
또한 구글 드라이브를 이용하여 파일을 Mount하여 간접적으로 열고 닫는데에는 훨씬 더 많은 시간이 걸릴 수 있다.
그렇기에 이러한 성능 문제를 해결하기 위해 데이터를 특정 형식으로 바꾸면 조금 더 파일 접근을 용이하게 하고, 반복에도 강하게 할 수 있다.
이 책에서는 HDF5(Hierarchical Data Format 5)라는 포맷을 사용하여 이미지를 처리한다.
이 포맷은 용량이 매우 큰 데이터에 효과적으로 접근하기 위해 만들어진 성숙한 데이터 형식으로, 공학이나 과학 분야에서 널리 사용된다.
HDF5 Package가 계층적이라 불리는 이유는, 하나 이상의 그룹을 가질 수 있기 때문이다.
또한 그룹 안에 여러 개의 데이터셋이 포함될 수도 있으며, 그룹 안에 그룹이 존재하는 것도 가능하다.
이 방법은 우리에게 익숙한 폴더 구조와 비슷하다.
HDF5 포맷과 이를 이용한 많은 라이브러리는 조금 더 나은 성능을 위해 많은 기능을 장착하고 있다.
어떤 라이브러리는 느린 속도를 개선하기 위해 데이터를 압축하여 전송하는 방법을 이용하기도 한다.
또 어떤 라이브러리는 데이터를 자동으로 메모리에 매핑하여 조금 더 성능을 높이는 방법을 사용하기도 한다.
이러한 기능 없이 구글 드라이브에 Mount하여 몇천 장의 이미지를 직접 다루는 것은 실용적이지 않다.
만약 구글 드라이브를 사용하지 않고 Local 저장소를 사용하여 작업한다고 해도, HDF5 형식을 이용하는 것이 머신러닝 작업을 그나마ㅏ 개선할 수 있는지 확인하여 보는 것이 좋다. (특히나 메모리에서 모두 처리할 수 없는 용량일 때)
1.4) 데이터 가져오기
https://hanbit.co.kr/support/supplement_survey.html?pcode=B9417661237
위의 링크에서 CelebA 데이터셋을 다운로드하고 20,000개의 이미지를 추출하여 HDF5 파일로 묶자.
학습하고 실험해보는 것이 목적이므로 202,599개의 이미지를 모두 사용하지는 않고 20,000개의 이미지를 사용한다.
Local File을 이용하기에 아래와 같이 HDF5 파일 경로인 hdf5_file을 내 컴퓨터에 맞게 수정한다.
아래의 코드는 20,000개 이미지의 압축을 풀고, 다시 묶는 코드이다.
이미지를 포함한 HDF5 파일 celeba_aligned_small.h5py가 celeb 폴더에 생성되얐다.
앞으로 해당 파일을 이용해 실습을 진행할 계획이다.
%time
# location of the HDF5 package, yours may be under /gan/ not /myo_gan/
hdf5_file = '/content/drive/MyDrive/AI/BITAmin/Project/2024_spring/celeba/celeba_aligned_small.h5py'
# how many of the 202,599 images to extract and package into HDF5
total_images = 20000
with h5py.File(hdf5_file, 'w') as hf:
count = 0
with zipfile.ZipFile('/content/drive/MyDrive/AI/BITAmin/Project/2024_spring/celeba/source.zip', 'r') as zf:
for i in zf.namelist():
if (i[-4:] == '.jpg'):
# extract image
ofile = zf.extract(i)
img = imageio.v2.imread(ofile)
os.remove(ofile)
# add image data to HDF5 file with new name
hf.create_dataset('img_align_celeba/'+str(count)+'.jpg', data=img, compression="gzip", compression_opts=9)
count = count + 1
if (count%1000 == 0):
print("images done .. ", count)
pass
# stop when total_images reached
if (count == total_images):
break
pass
pass
pass
CPU times: user 2 µs, sys: 1e+03 ns, total: 3 µs
Wall time: 5.48 µs
images done .. 1000
images done .. 2000
images done .. 3000
images done .. 4000
images done .. 5000
images done .. 6000
images done .. 7000
images done .. 8000
images done .. 9000
images done .. 10000
images done .. 11000
images done .. 12000
images done .. 13000
images done .. 14000
images done .. 15000
images done .. 16000
images done .. 17000
images done .. 18000
images done .. 19000
images done .. 20000
1.5) 데이터 살펴보기
이제 HDF5 파일에 어떻게 접근하는지 알아보고, 사진이 실제로 Celebrity에 해당하는지 확인해보자.
import h5py, zipfile, imageio, os
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from PIL import Image, ImageFile
ImageFile.LOAD_TRUNCATED_IMAGES = True
HDF5 파일을 다루기 위해 h5py를 import 한다.
h5py 라이브러리는 HDF5 패키지를 굉장히 pythonic 하게 이용할 수 있게 해준다.
Group이나 Dataset을 python dictionary 객체처럼 사용하면 된다.
h5py 라이브러리를 이용하여 HDF5 파일을 읽기 모드를 연다. 이후 파일 객체를 순회하며 최상단에 있는 그룹의 이름을 출력한다.
with h5py.File(
"/content/drive/MyDrive/AI/BITAmin/Project/2024_spring/celeba/celeba_aligned_small.h5py",
"r",
) as file_object:
for group in file_object:
print(group)
pass
img_align_celeba
img_align_celeba 라는 group 하나만 보인다.
이후 python dictionary에 접근하듯이 속성값을 통해 모든 이미지를 담은 데이터셋을 이용할 수 있다.
물론 dataset["1.jpg"]와 같이 개별적인 이미지에도 접근이 가능하다.
이미지 자체는 HDF5 형식으로 저장되지만, numpy 행렬로 쉽게 바꿀 수 있다.
with h5py.File(
"/content/drive/MyDrive/AI/BITAmin/Project/2024_spring/celeba/celeba_aligned_small.h5py",
"r",
) as file_object:
dataset = file_object["img_align_celeba"]
image = np.array(dataset["1.jpg"])
plt.imshow(image, interpolation="none")
pass
image.shape
(218, 178, 3)
살펴보니, 높이 218 pixel, 너비 178 pixel 이고 RGB 3개의 Channel로 구성된 image이다.
2. GAN Implementation
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
2.1) Dataset Class
CelebA 데이터셋을 사용하기 위해 직접 custom 하여 Dataset Class를 만들어보자.
class CelebADataset(Dataset):
def __init__(self, file_path):
super(CelebADataset, self).__init__()
self.file_object = h5py.File(file_path, "r")
self.dataset = self.file_object["img_align_celeba"]
pass
def __len__(self):
return len(self.dataset)
def __getitem__(self, idx):
if (idx >= len(self.dataset)):
raise IndexError(f"index {idx} is out of range")
img = np.array(self.dataset[str(idx)+".jpg"])
return torch.cuda.FloatTensor(img) / 255.0
def plot_image(self, idx):
plt.imshow(np.array(self.dataset[str(idx)+".jpg"]), interpolation='nearest')
pass
pass
Constructor __init__( )은 HDF5 파일을 열고 img_align_celeba로 각각의 이미지에 접근할 수 있게 한다.
__len__( )는 CelebA Dataset의 길이를 반환해준다.
__getitem__( )은 idx에 대응되는 이미지를 tensor를 0~1 사이의 값으로 Normalize해서 반환한다.
transforms.ToTensor( )는 [0, 255]의 value와 $H \times W \times C$의 Shape을 가지는 np.ndarray나 PIL Image를 [0.0, 1.0] 사이의 value와 $C \times H \times W$의 Shape을 가지는 torch.FloatTensor로 변환한다.
만약 실제 데이터셋의 item 개수보다 index가 더 클 경우 직접적으로 IndexError 예외를 발생할 필요가 있다.
행렬이나 일반적인 DataFrame의 경우 index가 범위를 벗어나면 알아서 해당 예외가 발생하지만,
지금처럼 HDF5 데이터셋에 접근할 때는, 범위를 벗어나는 요청을 했을 때 pytorch가 기대하는 IndexError가 아닌 오류를 발생하기 때문에 다음과 같이 직접 지정을 해줘야 한다.
이제 이 CelebADataset Class를 이용하여 객체를 하나 만들고, Index를 통해 접근해서 어떠한 이미지가 보이는지 확인해보자.
celeba_dataset = CelebADataset("/content/drive/MyDrive/AI/BITAmin/Project/2024_spring/celeba/celeba_aligned_small.h5py")
# data 확인
celeb_dataset.plot_image(43)
2.2) Discriminator
이제 Discriminator Class를 정의하여 판별기를 만들어보자.
Discriminator는 input image가 Real 인지, Fake 인지 분류하는 역할을 한다.
MNIST와의 Discriminator와 다른 점은 Image의 size와 Channel 개수이다.
각 이미지는 $218 \times 178 \times 3$의 shape을 가지기 때문에 FC Layer가 총 116, 412개의 노드로 입력을 받아야 한다.
그렇다면 116, 412개의 노드를 어떻게 정렬하여 Discriminator에 투입할까?
어차피 FC Layer이기 때문에 일관된 기준으로 pixel들을 정렬만 해주면 정렬하는 순서는 크게 상관없다.
마치 이미지의 행을 따라서 연속으로 정렬하고, 행의 끝에 이르면 다음 행으로 자연스럽게 이어지는 순서와 같이 일관된 기준으로 정렬만 해주면 그 순서는 크게 상관이 없다.
한 레이어의 모든 노드는 다음 레이어의 모든 노드와 연결되어 있으므로 pixel값을 입력 텐서의 어느 곳에 위치시켜도 특별히 더 나을 것이 없다는 뜻이다.
따라서 Full-Color image도 이와 동일하게 116, 412의 1D tensor로 바꾸고 이를 FC Layer에 넣어주면 된다.
Q1. nn.Sequential( ) 안에서 tensor의 shape(size)을 바꿀 수 있는가?
A. 따로 Class를 정의하여 사용해야 된다. ex) View(shape)를 사용한다.
해당 코드는 Input Tensor를 내가 원하는 형태의 ouput Tensor의 형태로 바꿔주는 역할을 한다.
다만, 이때 parameter 값으로 "(내가 변형하고자 하는 shape)"가 들어가야 한다.
class View(nn.Module):
def __init__(self, shape):
super(View, self).__init__()
self.shape = shape,
def forward(self, x):
return x.view(*self.shape)
self.shape = shape, 처럼 뒤에 ,가 꼭 들어가야 한다.
https://github.com/pytorch/vision/issues/720
그 이유는 self.shape = shape로 할 경우에, 1D Tensor로 바꿀 때에 문제가 생긴다.
shape가 (218 * 178 * 3)와 같이 integer로 나올 경우 x.view( )안의 인자로 integer가 들어가는 문제가 생길 수 있다.
따라서, tuple과 simple integer를 동시에 처리할 수 있도록 shape, 로 받아 simple integer도 하여금 tuple로 처리되도록 설정한다.
다만 이 방식을 사용하면, shape가 이미 튜플이거나 리스트와 같은 다른 iterable한 객체일 때도, 해당 객체를 튜플의 첫 번째 요소로 갖는 새 튜플을 생성하게 된다.
따라서, 명확하게 shape가 정수 값일 때만 이 기법을 적용하고 싶다면, shape의 타입을 체크하는 추가적인 로직을 넣는 것이 좋다.
class View(nn.Module):
def __init__(self, shape):
super(View, self).__init__()
# shape가 정수일 경우에만 튜플로 변환
self.shape = (shape,) if isinstance(shape, int) else shape
def forward(self, x):
return x.view(*self.shape)
Q2. 위의 View class에서 x.view(*self.shape)로 Unpacking하는 과정이 있는데, 만약 shape = (2, 5, 3)으로 들어온다면 x.view(2 5 3)이렇게 들어가서 오류가 나는 것이 아닌가?
A. 아니다. 오히려 unpacking되어 전달된 인자들은 각각 별도의 값으로 간주되어 x.view(2, 5, 3)으로 정확히 의도된 바와 동일하다.
헷갈리는 이유는 Python의 print 함수가 작동하는 방식 때문이다.
print 함수는 기본적으로 전달된 여러 인자 사이에 공백을 삽입하여 출력한다. 따라서, *shape를 사용하여 print 함수에 인자를 전달할 때, shape 튜플의 각 요소가 별도의 인자로 처리되어 사이에 공백이 들어가는 것이다.
하지만, 이것은 print 함수가 출력을 어떻게 포맷하는지에 대한 문제일 뿐, 실제로 함수에 인자를 전달하는 과정에서는 쉼표가 필요없다. 함수 내부에서는 전달된 인자들이 순서대로 처리되며, * 연산자를 통해 unpacking된 인자들은 각각 별도의 값으로 간주된다.
예를 들어, x.view(*shape)에서 shape가 (2, 5, 3)이면, 실제로 x.view 함수에는 2, 5, 3이라는 세 개의 별도 인자로 전달된다. 이는 함수 호출 문법과 관련된 것이며, print 함수의 출력 형태와는 별개의 문제다. 함수에 인자를 전달할 때는, 2, 5, 3과 같이 쉼표로 구분된 값들이 각각 독립적인 인자로 취급되어 처리된다.
이것이 바로 x.view(*shape)가 x.view(2, 5, 3)으로 해석되어 정확히 원하는 모양으로 텐서를 재구성하는 이유다. print 함수의 출력과 함수 호출 시 인자 전달 방식 사이의 이 차이를 이해하는 것이 중요하다.
def generate_random_image(shape):
random_image = torch.rand(shape)
return random_image
class View(nn.Module):
def __init__(self, shape):
super(View, self).__init__()
# shape가 정수일 경우에만 튜플로 변환
self.shape = (shape,) if isinstance(shape, int) else shape
def forward(self, x):
return x.view(*self.shape)
Discriminator 코드는 다음과 같다.
class Discriminator(nn.Module):
def __init__(self):
super(Discriminator, self).__init__()
self.model = nn.Sequential(
View(218 * 178 * 3),
nn.Linear(218 * 178 * 3, 100),
nn.LeakyReLU(inplace=True),
nn.LayerNorm(100),
nn.Linear(100, 1),
nn.Sigmoid(),
)
self.loss_function = nn.BCELoss()
self.optimizer = optim.Adam(self.parameters(), lr=1e-4)
self.counter = 0
self.progress = []
pass
def forward(self, x):
return self.model(x)
def train(self, inputs, targets):
inputs, targets = inputs.to(device), targets.to(device)
outputs = self.forward(inputs)
loss = self.loss_function(outputs, targets)
self.counter += 1
if self.counter % 10 == 0:
self.progress.append(loss.item())
pass
if self.counter % 10000 == 0:
print(f"counter = {self.counter}")
pass
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
def plot_progress(self):
df = pd.DataFrame(self.progress, columns=["loss"])
df.plot(
ylim=0,
figsize=(16,8),
alpha=0.1,
marker=".",
grid=True,
yticks=(0, 0.25, 0.5, 1.0, 5.0)
)
pass
nn.Sequential( ) 안에서 tensor의 shape을 바꾸기 위해 View Class를 정의하고 nn.Module을 상속하여 Sequential안에서 다른 모듈과 마찬가지로 사용할 수 있도록 했다.
따라서, 해당 코드에서 사용된 View(3 * 218 * 178)은 3D Image Tensor를 1D Tensor로 변형하는 역할을 한다.
2.3) Discriminator Test
Discriminator를 설계한 후에,
Model이 적어도 Real Image와 임의의 노이즈가 섞인 임의의 pixel값을 구별할 수 있는 능력이 있는지 확인해봐야 한다.
%%time
D = Discriminator().to(device)
for image_data_tensor in celeba_dataset:
# real data
D.train(image_data_tensor, torch.cuda.FloatTensor([1.0]))
# fake data
D.train(generate_random_image((218, 178, 3)), torch.cuda.FloatTensor([0.0]))
pass
<timed exec>:5: UserWarning: The torch.cuda.*DtypeTensor constructors are no longer recommended. It's best to use methods such as torch.tensor(data, dtype=*, device='cuda') to create tensors. (Triggered internally at ../torch/csrc/tensor/python_tensor.cpp:83.)
counter = 10000
counter = 20000
counter = 30000
counter = 40000
CPU times: user 3min 20s, sys: 3.4 s, total: 3min 24s
Wall time: 3min 28s
D.plot_progress()
Loss가 대체로 낮게 나왔다는 것을 보아, Disciminator가 확실히 Real Image를 구별할 수 있는 능력은 있다는 것을 알 수 있다.
2.4) Generator
이제 Generator를 만들어 보자. 현재 Model은 size가 꽤나 있는 Full-Color Image를 훈련 중이므로 Generator 또한 수정해야 한다.
다시 말해, 3D Tensor를 결과로 내보내도록 바꾸어야 한다는 뜻이다.
Generator를 학습시킬 때, 평균이 0이고 분산이 제한된 특정 분포를 따르는 Normalized된 값들이 학습에 유리하다.
따라서, Gaussian Distribution을 따르는 값들을 Input으로 넣어준다.
def generate_random_seed(shape):
random_seed = torch.randn(shape)
return random_seed
Generator의 신경망은 Input Node 개수 = 100, Hidden Layer Node 개수 = 300로 설정했다.
마지막에는 1D Feature Vector를 다시 3D Tensor로 변형하기 위해 View( )를 사용했다.
class Generator(nn.Module):
def __init__(self):
super(Generator, self).__init__()
self.model = nn.Sequential(
nn.Linear(100, 3 * 10 * 10),
nn.LeakyReLU(inplace=True),
nn.LayerNorm(3 * 10 * 10),
nn.Linear(3 * 10 * 10, 218 * 178 * 3),
nn.Sigmoid(),
View((218, 178, 3)),
)
self.loss_function = nn.BCELoss()
self.optimizer = optim.Adam(self.parameters(), lr=1e-4)
self.counter = 0
self.progress = []
pass
def forward(self, x):
return self.model(x)
def train(self, D, inputs, targets):
inputs, targets = inputs.to(device), targets.to(device)
g_outputs = self.forward(inputs)
d_outputs = D.forward(g_outputs)
loss = D.loss_function(d_outputs, targets)
self.counter += 1
if self.counter % 10 == 0:
self.progress.append(loss.item())
pass
self.optimizer.zero_grad()
loss.backward()
self.optimizer.step()
def plot_progress(self):
df = pd.DataFrame(self.progress, columns=["loss"])
df.plot(
ylim=0,
figsize=(16,8),
alpha=0.1,
marker=".",
grid=True,
yticks=(0, 0.25, 0.5, 1.0, 5.0)
)
pass
2.5) Generator result check
Generator가 제대로 된 형태의 결과물을 만들어내는지 확인해봐야 한다.
임의의 Gaussian 분포를 따르는 100개의 Value들을 input으로 넣어 Generator가 지정된 크기의 3D Tensor를 반환하는지 확인한다.
G = Generator().to(device)
output = G.forward(generate_random_seed(100).to(device))
img = output.detach().cpu().numpy()
plt.imshow(img, interpolation='none', cmap='Blues')
plt.show()
확인해보니, 올바른 크기이고 임의의 색으로 채워진 이미지를 확인할 수 있다.
2.6) GAN Training
GAN Training에 사용된 반복문은 이전에 만들었던 구조와 거의 비슷하다.
%%time
# Discriminator, Generator 생성
D = Discriminator().to(device)
G = Generator().to(device)
epochs = 6
for epoch in range(epochs):
print(f"epoch = {epoch + 1}")
# Discriminator와 Generator 훈련
for image_data_tensor in celeba_dataset:
# Discriminator 훈련: real data에 대해 학습
D.train(image_data_tensor, torch.cuda.FloatTensor([1.0]))
# Discriminator 훈련: fake data에 대해 학습
D.train(G.forward(generate_random_seed(100).to(device)).detach(), torch.cuda.FloatTensor([0.0]))
# Generator 훈련: Discriminator 속이기
G.train(D, generate_random_seed(100), torch.cuda.FloatTensor([1.0]))
pass
pass
epoch 6으로 훈련했더니, 다음과 같은 시간이 걸렸다.
CPU times: user 51min 8s, sys: 29 s, total: 51min 37s
Wall time: 52min 12s
Training이 어느정도 안정되어 보이고, Loss 또한 Discriminator와 Generator가 모두 비슷한 값으로 수렴하고 있다.
사실 확인해보면 Discriminator와 Generator모두 $ln(2) = 0.69$라는 이상적인 Loss값에 수렴하고 있다.
이제 Generator를 통해 나온 이미지들을 살펴보자. 아래의 코드는 6개의 이미지들을 $3 \times 2$ 의 격자 형태로 출력하는 코드이다.
# 2행 3열로 생성된 이미지 출력
f, axarr = plt.subplots(2, 3)
for i in range(2):
for j in range(3):
output = G.forward(generate_random_seed(100).to(device))
img = output.detach().cpu().numpy()
axarr[i, j].imshow(img, interpolation='none', cmap='Blues')
pass
pass
3. 정리
1. 색상은 많은 경우 R, G, B 값으로 모델링된다.
Full-Color Image는 보통 3개의 Layer에 담긴 pixel값들의 행렬로 표현이 되는데,
각 Layer는 R, G, B Channel을 나타내며, 형태는 $Height \times Width \times 3$ 이다.
2. 발전된 형식의 HDF5를 사용하면 랜덤 액세스를 자주 하는 데에 특화된 형식으로 리패키징하여 데이터를 더 효율적으로 처리한다.
3. GAN은 훈련 데이터를 기억하지 않는다.
GAN은 훈련 데이터의 Probability Distribution(확률 분포)를 파악하고 이를 재현한 데이터를 생성하기 위해 노력하다.