Project/pytorch

[pytorch+opencv] 졸음 감지 프로그램 (sleepy eyes detector)

ys_cs17 2020. 6. 17. 16:30
반응형

이 글은 기초적인 CNN 지식을 가지신 분, opencv, dlib을 사용하시거나 경험이 있으신 분, pytorch 기본 이상의 지식을 가지신 분들께 추천드립니다.

CNN: cs231n 또는 제가 리뷰한 https://ys-cs17.tistory.com/category/cs231n을 참고해주세요

opencv, dlib: 파이썬으로 만드는 opencv 프로젝트[이세우 저] 서적을 추천합니다.

pytorch: https://tutorials.pytorch.kr/beginner/deep_learning_60min_blitz.html 정도의 지식만 있으면 문제없습니다.

 

해당 프로젝트는 https://github.com/kairess/eye_blink_detector를 참고하였습니다.

 

kairess/eye_blink_detector

Eye blink(Closeness-Openess) detection using CNN (Keras) - kairess/eye_blink_detector

github.com

전체 코드는 https://github.com/yunseokddi/Pytorch_dev/tree/master/sleep_detect를 참고해주시기 바랍니다.

 

1. Overview

우리의 최종적인 목표는 해당 객체 (사람)가 졸고 있는 상태인지 깨어 있는 상태인지를 파악하는 것입니다.

쉽게 말하면 눈이 몇 초 이상 감기면 졸고 있다고 판단하면 됩니다. 그러면 우선 눈의 위치를 파악하여 눈이 감긴 상태인지 떠있는 상태인지 판단하면 됩니다.

저는 우선 dlib를 사용하여 눈으로 판단되는 좌표 값을 얻어내고 (opencv+dlib) 그러고 나서 해당 눈의 상태를 파악하는 것으로 (pytorch) 구성하였습니다.

눈의 좌표값을 얻어내는 과정에서 dlib를 사용한 이유는 실험해본 결과 굳이 이 부분까지 한 번에 묶어 object detection으로 문제를 해결하면 좋지만 우리의 최종적인 목표는 mobile (android)에 적용시키는 것이고, 눈 같은 작은 부분은 적은 parameter로 될 것 같지 않다고 판단하여 이 부분은 deep learning을 사용하지 않았습니다.

눈이 감겼는지 판단하는 classification 부분은 다른 유명 네트워크를 사용해도 되지만 간단한 cnn으로 충분한 accuracy에 도달하여 custom network model을 사용하였습니다.

 

2. Development environment

OS: Ubuntu 18.04 LTS

Language: python 3.6.8

Interpreter: pip3

GPU: RTX2070 SUPER

CUDA: 10.2

 

Library

pytorch: 1.5.0

torchvision: 0.6.0

numpy: 1.18.5

opencv-python: 4.2.0.34

dlib: 19.20.0

matplotlib: 3.2.1

imutils: 0.5.3

 

Dataset: npy 파일 형식의 눈 데이터 (git 참고 바랍니다.)

 

3. Codes

A) data_loader.py

Deep learning project에서 가장 까다롭고 시간이 오래 걸리는 부분은 data 처리 과정입니다.

이번 프로젝트에서 사용되는 이미지 데이터는 png, jpg 형식이 아닌 npy라는 확장자를 가진 이미지를 가지고 진행합니다. npy는 numpy 자료형으로만 저장하지만 데이터의 용량이 매우 적다는 점에서 유용합니다.

data_loader는 간단한 custom dataset class로 구성되어있습니다.

pytorch의 custom dataset class에 대해 잘 모르시면

https://tutorials.pytorch.kr/beginner/data_loading_tutorial.html 문서를 참고해주세요.

from torch.utils.data import Dataset
import torch


class eyes_dataset(Dataset):
    def __init__(self, x_file_paths, y_file_path, transform=None):
        self.x_files = x_file_paths
        self.y_files = y_file_path
        self.transform = transform

    def __getitem__(self, idx):
        x = self.x_files[idx]
        x = torch.from_numpy(x).float()

        y = self.y_files[idx]
        y = torch.from_numpy(y).float()

        return x, y

    def __len__(self):
        return len(self.x_files)

 

가장 먼저 우리가 가지고 있는 eyes_dataset에 대해 class를 구성해줍니다.

x_file의 shape는 (2586,26,34,1) 즉 2586개 데이터의 width, heigh, channel이 각각 26, 34, 1로 구성되어 있고, y_file의 shape는 (2586,1)로 2586개의 데이터 각각의 라벨링이 있습니다. 각 라벨링은 눈을 감으면 0, 뜨면 1로 구성되어 있습니다.

우리의 데이터는 앞서 말씀드린 바와 같이 numpy로 구성되어 있습니다. 하지만 우리는 tensor를 사용하여 GPU 연산을 해야 되기 때문에 torch.from_numpy()를 사용하여 자료형을 tensor로 변화시켜줍니다.

 

B) model.py

import torch.nn as nn
import torch.nn.functional as F
from torchsummary import summary

class Net(nn.Module):
    def __init__(self):
        super(Net, self).__init__()

        self.conv1 = nn.Conv2d(1, 32, kernel_size=3, stride=1, padding=1)
        self.conv2 = nn.Conv2d(32, 64, kernel_size=3, stride=1, padding=1)
        self.conv3 = nn.Conv2d(64, 128, kernel_size=3, stride=1, padding=1)
        self.fc1 = nn.Linear(1536, 512)
        self.fc2 = nn.Linear(512, 1)

    def forward(self, x):
        x = F.max_pool2d(F.relu(self.conv1(x)),2)
        x = F.max_pool2d(F.relu(self.conv2(x)), 2)
        x = F.max_pool2d(F.relu(self.conv3(x)), 2)
        x = x.reshape(-1, 1536)
        x = F.relu(self.fc1(x))
        x = self.fc2(x)


        return x

model은 binary classification이고, 해결하려는 문제가 그렇게 어렵지 않기 때문에 batch normalization, drop out과 같은 기법들은 사용하지 않았습니다.

conv, activation, pool의 3번의 과정을 거쳐 2번의 linear layer을 거치게 되면 최종적인 값이 나오게 되며 이 값을 통해 loss를 구하고, 추후에는 Probability를 구해 boundary를 0.5로 잡고 눈을 감았는지 떴는지 판단합니다.

model = Net().to('cuda')
summary(model, (1,26,34))
----------------------------------------------------------------
        Layer (type)               Output Shape         Param #
================================================================
            Conv2d-1           [-1, 32, 26, 34]             320
            Conv2d-2           [-1, 64, 13, 17]          18,496
            Conv2d-3            [-1, 128, 6, 8]          73,856
            Linear-4                  [-1, 512]         786,944
            Linear-5                    [-1, 1]             513
================================================================
Total params: 880,129
Trainable params: 880,129
Non-trainable params: 0
----------------------------------------------------------------
Input size (MB): 0.00
Forward/backward pass size (MB): 0.37
Params size (MB): 3.36
Estimated Total Size (MB): 3.74
----------------------------------------------------------------

summary를 통해 input이 (26, 34)일 때  모델 전체를 요약한 결과입니다.

전체적인 model의 parameter size는 3.7MB이므로 충분히 모바일 환경에서 적용시킬 수 있습니다. 또한 CPU 환경에서도 학습을 시킬 수 있습니다.

 

C) train.py

import numpy as np
import matplotlib.pyplot as plt
import torch
import torch.nn as nn
from torchvision.transforms import transforms
from torch.utils.data import DataLoader
from data_loader import eyes_dataset
from model import Net
import torch.optim as optim

train 코드를 작성하기 전 학습을 위한 pytorch, 데이터 시각화를 위한 matplotlib 라이브러리들과 전에 작성한 model, data 처리 클래스들을 import 해줍니다.

x_train = np.load('./dataset/x_train.npy').astype(np.float32)  # (2586, 26, 34, 1)
y_train = np.load('./dataset/y_train.npy').astype(np.float32)  # (2586, 1)

train_transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.RandomRotation(10),
    transforms.RandomHorizontalFlip(),
])

train_dataset = eyes_dataset(x_train, y_train, transform=train_transform)

다음으로는 data loader에 관련된 코드를 작성하여봅시다.

우리가 사용할 데이터는 dataset 폴더 안에 있고, 파일 확장자는 앞에서 말씀드린 것처럼. npy입니다. 이를 불러오기 위해선 np.load() 함수를 사용합니다. 그러고 나서 train data는 transforms를 사용하여 이미지를 tensor로 변환시킨후 randomrotation, random horizontal flip을 사용하여 데이터를 변형시킵니다.

그리고 나서 이렇게 처리된 데이터들을 앞에서 정의하였던 eyes_dataset class의 parameter로 넣어줍니다.

이렇게 까지 해서 데이터 처리가 끝났습니다!

# --------데이터 출력----------
plt.style.use('dark_background')
fig = plt.figure()

for i in range(len(train_dataset)):
    x, y = train_dataset[i]

    plt.subplot(2, 1, 1)
    plt.title(str(y_train[i]))
    plt.imshow(x_train[i].reshape((26, 34)), cmap='gray')

    plt.show()

matplotlib를 사용하여 우리가 데이터를 잘 불러왔는지, 라벨링이 잘되었는지를 확인해봅시다.

(그대로 코드를 실행시키면 전체 이미지가 순차적으로 출력되니 range를 조절해 주세요)

눈을 뜸 (1)
눈을 감음 (0)

이와 같이 눈을 떴을 때는 1, 감았을 때는 0으로 라벨링이 제대로 되었고,  (26,34) size로 npy 이미지도 잘 불러온 것을 볼 수 있습니다.

PATH = 'weights/trained.pth'

train_dataloader = DataLoader(train_dataset, batch_size=32, shuffle=True, num_workers=4)

model = Net()
model.to('cuda')

criterion = nn.BCEWithLogitsLoss()
optimizer = optim.Adam(model.parameters(), lr=0.0001)

epochs = 50

다음은 trained parameter를 저장할 path를 설정해 주시고, train, val data loader를 작성해 줍니다. batch size는 본인의 하드웨어 환경에 따라 변경하여 주시고 윈도 환경에서는 num_workers를 0으로 설정해야 오류가 나지 않습니다. shuffle은 train시에는 true로 설정합니다. criterion은 우리는 이진 분류를 위한 코드니 BCE를 사용하고, optimizer는 무난한 adam을 사용합니다. sgd를 사용해도 문제는 없을 것 같습니다. epoch은 50입니다. 또한 우리의 model은 gpu를 사용하기 때문에 cuda를 선언해 줍니다.

for epoch in range(epochs):
    running_loss = 0.0
    running_acc = 0.0

    model.train()

    for i, data in enumerate(train_dataloader, 0):
        input_1, labels = data[0].to('cuda'), data[1].to('cuda')

        input = input_1.transpose(1, 3).transpose(2, 3)

        optimizer.zero_grad()

        outputs = model(input)

        loss = criterion(outputs, labels)
        loss.backward()
        optimizer.step()

        running_loss += loss.item()
        running_acc += accuracy(outputs, labels)

        if i % 80 == 79:
            print('epoch: [%d/%d] train_loss: %.5f train_acc: %.5f' % (
                epoch + 1, epochs, running_loss / 80, running_acc / 80))
            running_loss = 0.0

print("learning finish")
torch.save(model.state_dict(), PATH)

pytorch에서는 train전 model.train()으로 학습 모드를 선언을 해주는 것이 좋습니다. (안 해도 상관은 없습니다.)

두 번째 for 문을 보면 우선 data의 index 0은 이미지이고, 1은 라벨입니다. 이를 그냥 받아오면 안 되고 꼭 to('cuda')로 받아야지만 gpu를 사용할 수 있습니다. 그러고 나서 우리의 이미지를 transpose를 통해 형상을 변화시킨 후 model에 넣어줍니다. model의 output을 criterion을 통해 loss를 구하고, optimizer.step()을 통해 loss update를 해줍니다.

그러고 나서 running loss와 running acc를 구합니다. accuracy 함수는 아래에서 확인해봅시다.

 

def accuracy(y_pred, y_test):
    y_pred_tag = torch.round(torch.sigmoid(y_pred))

    correct_results_sum = (y_pred_tag == y_test).sum().float()
    acc = correct_results_sum / y_test.shape[0]
    acc = torch.round(acc * 100)

    return acc

accuracy 함수도 식을 이해하는데 크게 어려움이 없습니다. 우선 예측값 (model output)과 label값을 parameter로 불러옵니다. 그러고 예측값을 sigmoid를 통해 확률화 시키고, round를 이용하여 0또는 1로 변환시킵니다. (0.5이하는 0, 초과는 1) 그리고 나서 맞춘 개수를 count 한 뒤, 전체 개수많큼 나눈 후 100을 곱한 값을 return 해줍니다.

epoch: [1/50] train_loss: 0.38541 train_acc: 85.15000
epoch: [2/50] train_loss: 0.14086 train_acc: 96.11250
epoch: [3/50] train_loss: 0.10041 train_acc: 97.37500
epoch: [4/50] train_loss: 0.07550 train_acc: 97.78750
epoch: [5/50] train_loss: 0.04663 train_acc: 98.53750
epoch: [6/50] train_loss: 0.03403 train_acc: 99.21250
epoch: [7/50] train_loss: 0.02533 train_acc: 99.55000
epoch: [8/50] train_loss: 0.02284 train_acc: 99.40000
epoch: [9/50] train_loss: 0.01691 train_acc: 99.58750
epoch: [10/50] train_loss: 0.01310 train_acc: 99.70000
epoch: [11/50] train_loss: 0.01021 train_acc: 99.73750
epoch: [12/50] train_loss: 0.00665 train_acc: 99.92500
epoch: [13/50] train_loss: 0.00629 train_acc: 99.88750
epoch: [14/50] train_loss: 0.00510 train_acc: 99.96250
epoch: [15/50] train_loss: 0.00474 train_acc: 99.92500
epoch: [16/50] train_loss: 0.00271 train_acc: 100.00000
epoch: [17/50] train_loss: 0.00216 train_acc: 100.00000
epoch: [18/50] train_loss: 0.00184 train_acc: 100.00000
epoch: [19/50] train_loss: 0.00203 train_acc: 100.00000
.
.
.
epoch: [43/50] train_loss: 0.00011 train_acc: 100.00000
epoch: [44/50] train_loss: 0.00009 train_acc: 100.00000
epoch: [45/50] train_loss: 0.00009 train_acc: 100.00000
epoch: [46/50] train_loss: 0.00008 train_acc: 100.00000
epoch: [47/50] train_loss: 0.00008 train_acc: 100.00000
epoch: [48/50] train_loss: 0.00007 train_acc: 100.00000
epoch: [49/50] train_loss: 0.00007 train_acc: 100.00000
epoch: [50/50] train_loss: 0.00006 train_acc: 100.00000

train 코드의 결과를 보게 되면 loss는 점점 0으로,  acc는 100으로 수렴하는 모습을 볼 수 있습니다.

학습을 돌리기 전 validation을 사용할 생각을 했었는데 굳이 할 필요가 없어 validation data set을 test data로 변경하기로 했습니다. 또한 같은 이유로 batch normalization, drop out과 같은 기법을 사용하지 않았습니다.

해당 결과가 나온 이유는 아마 이미지 자체가 size가 작고, 무엇보다 어렵지 않은 binary classification이기 때문인 것 같습니다.

그리고 train후 weights/trained.pth라는 학습된 weight가 생성되었을 것입니다. 이를 가지고 test code에 적용시켜봅시다.

 

D) test.py

PATH = './weights/trained.pth'

x_test = np.load('./dataset/x_val.npy').astype(np.float32)  # (288, 26, 34, 1)
y_test = np.load('./dataset/y_val.npy').astype(np.float32)  # (288, 1)

test_transform = transforms.Compose([
    transforms.ToTensor()
])

test_dataset = eyes_dataset(x_test, y_test, transform=test_transform)

test_dataloader = DataLoader(test_dataset, batch_size=1, shuffle=False, num_workers=4)

model = Net()
model.to('cuda')
model.load_state_dict(torch.load(PATH))
model.eval()

count = 0

train 코드와 비슷한 부분은 설명을 생략하겠습니다.

우선 data를 불러오는 방법은 같습니다. 하지만 test data set은 transform을 적용시킬 필요가 없어 그냥 단순히 tensor로만 변형시켜줍니다.

그리고 train과는 달리 shuffle을 사용하지 않았고, batch size는 디버그 때문에 1로 설정하였습니다.

train과 마찬가지로 model을 cuda로 선언하고 무엇보다 중요한 것은 load_state_dict으로 해당 path에 있는 weight 값을 불러오고 model.eval()을 통해 테스트 모델이라는 것을 알려야 됩니다.

with torch.no_grad():
    total_acc = 0.0
    acc = 0.0
    for i, test_data in enumerate(test_dataloader, 0):
        data, labels = test_data[0].to('cuda'), test_data[1].to('cuda')

        data = data.transpose(1, 3).transpose(2, 3)

        outputs = model(data)

        acc = accuracy(outputs, labels)
        total_acc += acc

        count = i

    print('avarage acc: %.5f' % (total_acc/count),'%')

print('test finish!')

본격적인 test 부분을 보시게 되면 생각보다 매우 간단합니다.

train과 거의 같지만 다른 부분은 loss를 구할 필요가 없고, 그냥 단순히 아까 선언했던 accuracy를 통해 정답의 정확도를 더해주고 count로 나누어서 평균 acc를 구할 수 있습니다.

avarage acc: 99.65157 %
test finish!

무려 99.65%의 정확도를 가집니다.

 

E) detect.py

import cv2
import dlib
import numpy as np
from model import Net
import torch
from imutils import face_utils

detect에 사용되는 라이브러리들입니다. 코드는 거의 대부분 opencv로 구성되어 있습니다.

dlib, imutils 같은 경우에는 얼굴 랜드마크 인식 때문에 쓰입니다. (머신 러닝)

IMG_SIZE = (34,26)
PATH = './weights/trained.pth'

detector = dlib.get_frontal_face_detector()
predictor = dlib.shape_predictor('shape_predictor_68_face_landmarks.dat')

model = Net()
model.load_state_dict(torch.load(PATH))
model.eval()

n_count = 0

이미지 사이즈는 전체 이미지 사이즈가 아니라 얼굴 이미지에서 눈으로 인식하여 crop 한 2개 이미지에 대한 사이즈입니다.

dlib의 얼굴을 인식할 수 있는 detector와 predictor를 선언해주고 눈을 감았는지 떴는지 판단하는 model을 불러옵니다. n_count는 프레임을 저장하는 변수입니다.

def crop_eye(img, eye_points):
  x1, y1 = np.amin(eye_points, axis=0)
  x2, y2 = np.amax(eye_points, axis=0)
  cx, cy = (x1 + x2) / 2, (y1 + y2) / 2

  w = (x2 - x1) * 1.2
  h = w * IMG_SIZE[1] / IMG_SIZE[0]

  margin_x, margin_y = w / 2, h / 2

  min_x, min_y = int(cx - margin_x), int(cy - margin_y)
  max_x, max_y = int(cx + margin_x), int(cy + margin_y)

  eye_rect = np.rint([min_x, min_y, max_x, max_y]).astype(np.int)

  eye_img = gray[eye_rect[1]:eye_rect[3], eye_rect[0]:eye_rect[2]]

  return eye_img, eye_rect

해당 함수는 이미지에서 눈 부분을 자르는 함수입니다. eye_points라는 parameter는 아래 사진 37~42, 43~48까지입니다.

 

face landmark 68

이 점을 통해 x1, x2, y1, y2의 좌표를 뽑습니다. opencv에서는 (0,0)에 해당하는 점은 왼쪽 맨 위입니다.

x의 중점은 cx,  y의 중점인 cy를 구하고, width와 height를 구한 뒤 마진 값까지 구해줍니다.

그다음 마진 값을 고려하여 x, y좌표를 구해주고 이를 통해 eye_rect 좌표를 만듭니다. 그리고 좌표에 해당하는 이미지를 grayscale로 변경하여 이 이미지와 방금 구한 eye_rect 좌표를 return 해줍니다.

 

def predict(pred):
  pred = pred.transpose(1, 3).transpose(2, 3)

  outputs = model(pred)

  pred_tag = torch.round(torch.sigmoid(outputs))

  return pred_tag

다음은 model을 통해 예측을 하는 함수입니다. 이 함수도 단순하게 들어온 눈 이미지를 transpose 하여 model input과 같은 size로 변경해주고 model을 통해 나온 output을 test code와 마찬가지로 round를 통해 값을 return 해줍니다.

while cap.isOpened():
  ret, img_ori = cap.read()

  if not ret:
    break

  img_ori = cv2.resize(img_ori, dsize=(0, 0), fx=0.5, fy=0.5)

  img = img_ori.copy()
  gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)

  faces = detector(gray)

  for face in faces:
    shapes = predictor(gray, face)
    shapes = face_utils.shape_to_np(shapes)

    eye_img_l, eye_rect_l = crop_eye(gray, eye_points=shapes[36:42])
    eye_img_r, eye_rect_r = crop_eye(gray, eye_points=shapes[42:48])


    eye_img_l = cv2.resize(eye_img_l, dsize=IMG_SIZE)
    eye_img_r = cv2.resize(eye_img_r, dsize=IMG_SIZE)
    eye_img_r = cv2.flip(eye_img_r, flipCode=1)

    # cv2.imshow('l', eye_img_l)
    # cv2.imshow('r', eye_img_r)

    eye_input_l = eye_img_l.copy().reshape((1, IMG_SIZE[1], IMG_SIZE[0], 1)).astype(np.float32)
    eye_input_r = eye_img_r.copy().reshape((1, IMG_SIZE[1], IMG_SIZE[0], 1)).astype(np.float32)


    eye_input_l = torch.from_numpy(eye_input_l)
    eye_input_r = torch.from_numpy(eye_input_r)


    pred_l = predict(eye_input_l)
    pred_r = predict(eye_input_r)

    if pred_l.item() == 0.0 and pred_r.item() == 0.0:
      n_count+=1

    else:
      n_count = 0


    if n_count > 100:
      cv2.putText(img,"Wake up", (120,160), cv2.FONT_HERSHEY_SIMPLEX, 1, (255,255,255),2)



    # visualize
    # state_l = 'O %.1f' if pred_l > 0.1 else '- %.1f'
    # state_r = 'O %.1f' if pred_r > 0.1 else '- %.1f'

    # state_l = state_l % pred_l
    # state_r = state_r % pred_r
    #
    #
    # cv2.rectangle(img, pt1=tuple(eye_rect_l[0:2]), pt2=tuple(eye_rect_l[2:4]), color=(255,255,255), thickness=2)
    # cv2.rectangle(img, pt1=tuple(eye_rect_r[0:2]), pt2=tuple(eye_rect_r[2:4]), color=(255,255,255), thickness=2)
    #
    # cv2.putText(img, state_l, tuple(eye_rect_l[0:2]), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255,255,255), 2)
    # cv2.putText(img, state_r, tuple(eye_rect_r[0:2]), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255,255,255), 2)


  cv2.imshow('result', img)
  if cv2.waitKey(1) == ord('q'):
    break

다음은 main에 해당하는 부분입니다. 우선 우리는 웹캠 기준으로 detect code를 실행시킬 것이기 때문에 웹캠에 해당하는 device number인 0으로 설정합니다. 그러고 이를 통해 읽은 비디오 프레임을 ret과 img_iri로 읽습니다.

cv2.resize를 통해 사이즈를 조절해주고 rgb image를 gray scale로 전환시킵니다.

detector를 통해 gray scale image에서 얼굴을 인식하고 predictor를 통해 face의 좌표에서 landmark를 추정합니다.

그리고 아까 선언한 crop_eye 함수를 통해 눈 부분만 crop을 하여 각각 eye_img_l, eye_rect_l, eye_img_r, eye_rect_r에 이미지와 눈 좌표를 저장합니다.

그리고 나서 눈이 인식된 이미지를 우리의 model input size에 맞게 조절합니다.

만약 눈만 인식한 결과를 보고 싶으면 cv2.imshow('l', eye_img_l), cv2.imshow('r', eye_img_r) 부분의 주석을 해제해주세요.

그다음은 우리의 이미지는 numpy 배열이니 torch.from_numpy를 통해 tensor로 변환해줍니다. 그 다음 우리의 model의 해당하는 predict를 통해 눈을 감았는지 판단합니다.

그 아래 if문은 만약 100 프레임 동안 눈을 감고 있다면 wake up이라는 문구가 나오도록 코드를 작성하였습니다.

맨 마지막으로 'q'를 입력하면 프로그램이 종료되게 합니다.

 

4. Result

원본 유튜브 동영상:https://www.youtube.com/watch?v=ruPaOWgA-cM&t=33s

 

다음 시간에는 오늘 제작한 코드를 안드로이드에 넣어봅시다.

반응형