AI와 가위바위보 게임 하기

프로젝트 소개


이 프로젝트는 AI를 배운다고 하면 그 분야에 대해 잘 모르는 사람들은 생소하고 거리감 있게 느끼는 사람들이 많다. 동아리 소개를 했을 때 “생성형 AI를 개발하는 동아리냐", ”다른 동아리들은 뭐하는 지 알겠는데 AI는 도대체 뭐를 배우는 거냐"는 이야기를 듣게 되었다.
나는 그 얘기를 듣고 그런 인식을 깨기 위에 간단하고 재미있는 프로젝트를 개발하여 사람들이 더 재미있게 AI를 접하게 하고싶다는 생각을 했다.
그러던 중 구글의 손 인식 모델에 대해 알게되었고 그걸 이용하여 가위바위보 게임을 만들면 재미있을 거라는 생각이 들었다. 나는 이 프로젝트가 AI를 처음 접해보는 누군가에게 흥미를 심어줄 수 있을 것이라 생각한다. AI가 무섭게 발전해 나가는 시대인 만큼 쉽게 접할 수 있는 이 프로젝트가 긍정적인 영향을 미칠 것이라 생각한다.

프로젝트 기획


이 프로젝트는 컴퓨터 비전과 딥러닝 기반 손 인식을 사용하여 개발되었다. 구글의 MediaPipe Hands라는 손 인식 모델을 불러오고 실시간 컴퓨터 비전 라이브러리인 OpenCV를 이용하여 웹캠을 제어하고 이미지 처리를 했다.
웹캠을 이용하여 영상이 입력 되면 OpenCV를 이용하여 전처리를 하고 Media Pipe으로 손 랜드마크를 추출하여 실행 코드 로직대로 제스처를 판단하여 승자를 결정한다. 흐름을 보자면 입력>전처리>AI 인식>판단>게임 로직>출력 순서이다.
개발환경은 MacBook Air M3에서 진행하였으며 카메라 역시 맥북의 내장 웹캠을 사용했다. 소프트웨어는 macOS에서 개발하였고 Python을 개발 언어로 채택하였다. 맥의 터미널에서 간단하게 실행하고 플레이할 수 있는 게임이다.
구글에서 이미 학습시킨 모델을 이용하였기 때문에 따로 데이터셋을 사용할 필요는 없었다. 또한 컴퓨터가 손 모양을 인식하여 주먹 가위 보자기 모양을 판단할 수 있게 하기 위해 각 손가락 관절이나 손 끝의 위치를 이용하여 판단을 구현했다.

프로젝트 결과


화면 구성은 총 세가지로 나뉘는데 대기 화면에서는 게임 제목, 조작법, 현재 점수를 표시하고 게임 화면에서는 카운트다운과 손 랜드마크 시각화가 표시된다. 마지막으로 결과화면에서는 선택 결과와 게임 결과가 표시된다. 손 감지시 초록색 점과 흰색 선으로 관절을 표시하는 랜드마크의 시각화가 일어난다.
스페이스바를 누르면 카운트다운이 시작되고 show! 표시에 맞춰 손 모양을 보여주면 된다. 그럼 결과 화면이 나왔다가 3초 뒤 자동으로 대기화면 전환 된다. Q 키를 눌러 게임 종료가 가능하다.
가상 큰 핵심 기능은 손 모양 인식이며, 핵심 알고리즘은 손가락 카운팅이다. 카운팅 알고리즘에 대해 알아보자면 손가락 끝과 관절의 좌표를 비교하여 손 모양을 비교하는 방식이다.
손가락이 펴지면 끝이 관절보다 위에 있을거고, 주먹일때와 그렇지 않을때의 엄지의 위치가 다를 것이다. 그걸 참고하여 Y좌표와 X좌표의 대소비교를 이용한 손가락 카운팅 알고리즘을 구현하였다.
실시간 처리가 자연스럽고 손모양 판단도 높은 정확도를 보이는 것이 장점이다.
무엇보다 손동작으로만 플레이 가능하기 때문에 간편하다. 대신 조명이 어두운 곳이나 배경이 복잡한 곳에서는 인식율이 하락한다. 또한 주먹 가위 보자기 말고 다른 손 모양을 냈을 때의 예외처리가 부족하다.

코드

import os # 운영체제 관리 및 조작 기능들이 담긴 모듈
import cv2
import mediapipe as mp
import numpy as np
import random
import time
import os

# 화면 설정
DISPLAY_W = 1280
DISPLAY_H = 720

# 색상 정의
C = {
    'bg': (15, 15, 20),
    'card': (30, 30, 40),
    'card_player': (60, 45, 35),
    'card_cpu': (45, 35, 55),
    'border': (50, 50, 60),
    'cyan': (230, 180, 80),
    'purple': (200, 120, 180),
    'green': (130, 210, 140),
    'red': (130, 130, 230),
    'yellow': (100, 200, 240),
    'white': (255, 255, 255),
    'gray': (140, 140, 150),
    'dark_gray': (80, 80, 90),
    'btn_quit': (60, 60, 180),
    'btn_quit_hover': (80, 80, 220),
}

BG_CACHE = None
MOUSE_POS = (0, 0)
MOUSE_CLICKED = False

def init_bg():
    global BG_CACHE
    BG_CACHE = np.full((DISPLAY_H, DISPLAY_W, 3), C['bg'], dtype=np.uint8)

def mouse_callback(event, x, y, flags, param):
    global MOUSE_POS, MOUSE_CLICKED
    MOUSE_POS = (x, y)
    if event == cv2.EVENT_LBUTTONDOWN:
        MOUSE_CLICKED = True

class Images:
    def __init__(self, path="assets"):
        self.path = path
        self.imgs = {}
        self.sized = {}
    
    def load(self):
        os.makedirs(self.path, exist_ok=True)
        
        for g in ["rock", "paper", "scissors"]:
            f = os.path.join(self.path, f"{g}.png")
            if os.path.exists(f):
                self.imgs[g] = cv2.imread(f, cv2.IMREAD_UNCHANGED)
        
        if len(self.imgs) < 3:
            self._create()
            for g in ["rock", "paper", "scissors"]:
                f = os.path.join(self.path, f"{g}.png")
                if os.path.exists(f):
                    self.imgs[g] = cv2.imread(f, cv2.IMREAD_UNCHANGED)
        
        print(f"Images loaded")
    
    def _create(self):
        try:
            from PIL import Image, ImageDraw
            
            size = 400
            
            img = Image.new('RGBA', (size, size), (0,0,0,0))
            d = ImageDraw.Draw(img)
            cx, cy = 200, 220
            d.rounded_rectangle([cx-80, cy-60, cx+80, cy+80], radius=40, fill=(255,255,255,255))
            d.ellipse([cx-110, cy-30, cx-60, cy+30], fill=(255,255,255,255))
            img.save(os.path.join(self.path, "rock.png"))
            
            img = Image.new('RGBA', (size, size), (0,0,0,0))
            d = ImageDraw.Draw(img)
            cx, cy = 200, 250
            d.rounded_rectangle([cx-70, cy-20, cx+70, cy+80], radius=35, fill=(255,255,255,255))
            for fx, fy, fw in [(cx-55, cy-130, 22), (cx-18, cy-145, 22), (cx+18, cy-140, 22), (cx+52, cy-120, 20)]:
                d.rounded_rectangle([fx-fw//2, fy, fx+fw//2, cy], radius=fw//2, fill=(255,255,255,255))
            d.rounded_rectangle([cx-105, cy-50, cx-65, cy+20], radius=15, fill=(255,255,255,255))
            img.save(os.path.join(self.path, "paper.png"))
            
            img = Image.new('RGBA', (size, size), (0,0,0,0))
            d = ImageDraw.Draw(img)
            cx, cy = 200, 250
            d.rounded_rectangle([cx-50, cy-10, cx+50, cy+70], radius=25, fill=(255,255,255,255))
            
            f1 = Image.new('RGBA', (size, size), (0,0,0,0))
            ImageDraw.Draw(f1).rounded_rectangle([cx-45, cy-150, cx-10, cy+10], radius=15, fill=(255,255,255,255))
            img = Image.alpha_composite(img, f1.rotate(15, center=(cx, cy), resample=Image.BICUBIC))
            
            f2 = Image.new('RGBA', (size, size), (0,0,0,0))
            ImageDraw.Draw(f2).rounded_rectangle([cx+10, cy-150, cx+45, cy+10], radius=15, fill=(255,255,255,255))
            img = Image.alpha_composite(img, f2.rotate(-15, center=(cx, cy), resample=Image.BICUBIC))
            
            d = ImageDraw.Draw(img)
            d.ellipse([cx+35, cy+5, cx+70, cy+40], fill=(255,255,255,255))
            d.ellipse([cx-80, cy-5, cx-45, cy+35], fill=(255,255,255,255))
            img.save(os.path.join(self.path, "scissors.png"))
            
        except ImportError:
            print("PIL not found")
    
    def draw(self, frame, gesture, center, size, color=None):
        key = gesture.lower()
        cache_key = f"{key}_{size}"
        
        if cache_key not in self.sized:
            img = self.imgs.get(key)
            if img is None:
                return
            self.sized[cache_key] = cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA)
        
        img = self.sized[cache_key].copy()
        x, y = center[0] - size//2, center[1] - size//2
        h, w = frame.shape[:2]
        
        if x < 0 or y < 0 or x + size > w or y + size > h:
            return
        
        if img.shape[2] == 4:
            alpha = img[:,:,3:4] / 255.0
            
            if color:
                for c in range(3):
                    img[:,:,c] = (img[:,:,c].astype(float) * color[c] / 255.0).astype(np.uint8)
            
            frame[y:y+size, x:x+size] = (
                alpha * img[:,:,:3] + (1 - alpha) * frame[y:y+size, x:x+size]
            ).astype(np.uint8)

def text(img, txt, pos, scale=1.0, color=C['white'], thick=2, center=False):
    x, y = pos
    if center:
        tw = cv2.getTextSize(txt, cv2.FONT_HERSHEY_SIMPLEX, scale, thick)[0][0]
        x -= tw // 2
    cv2.putText(img, txt, (x, y), cv2.FONT_HERSHEY_SIMPLEX, scale, color, thick, cv2.LINE_AA)

def box(img, x, y, w, h, color=C['card'], border=C['border']):
    cv2.rectangle(img, (x, y), (x+w, y+h), color, -1)
    cv2.rectangle(img, (x, y), (x+w, y+h), border, 2, cv2.LINE_AA)

def button(img, x, y, w, h, label, color, hover_color):
    global MOUSE_POS, MOUSE_CLICKED
    
    mx, my = MOUSE_POS
    is_hover = x <= mx <= x + w and y <= my <= y + h
    
    btn_color = hover_color if is_hover else color
    cv2.rectangle(img, (x, y), (x+w, y+h), btn_color, -1)
    cv2.rectangle(img, (x, y), (x+w, y+h), C['white'], 2, cv2.LINE_AA)
    
    text(img, label, (x + w//2, y + h//2 + 8), 0.7, C['white'], 2, True)
    
    clicked = is_hover and MOUSE_CLICKED
    return clicked

def draw_header(img, title, sub=None):
    text(img, title, (DISPLAY_W//2, 60), 1.5, C['white'], 3, True)
    
    tw = cv2.getTextSize(title, cv2.FONT_HERSHEY_SIMPLEX, 1.5, 3)[0][0]
    cv2.line(img, (DISPLAY_W//2 - tw//2, 75), (DISPLAY_W//2 + tw//2, 75), C['cyan'], 3, cv2.LINE_AA)
    
    if sub:
        text(img, sub, (DISPLAY_W//2, 100), 0.6, C['gray'], 2, True)

def draw_scores(img, score):
    bw, bh = 500, 60
    bx, by = (DISPLAY_W - bw) // 2, DISPLAY_H - bh - 20
    
    box(img, bx, by, bw, bh)
    
    cy = by + bh // 2
    
    text(img, "YOU", (bx + 50, cy - 8), 0.5, C['cyan'], 2, True)
    text(img, str(score['player']), (bx + 50, cy + 22), 1.2, C['white'], 2, True)
    
    cv2.line(img, (bx + 140, by + 10), (bx + 140, by + bh - 10), C['dark_gray'], 2)
    
    text(img, "DRAW", (bx + bw//2, cy - 8), 0.5, C['gray'], 2, True)
    text(img, str(score['draw']), (bx + bw//2, cy + 22), 1.2, C['white'], 2, True)
    
    cv2.line(img, (bx + bw - 140, by + 10), (bx + bw - 140, by + bh - 10), C['dark_gray'], 2)
    
    text(img, "CPU", (bx + bw - 50, cy - 8), 0.5, C['purple'], 2, True)
    text(img, str(score['computer']), (bx + bw - 50, cy + 22), 1.2, C['white'], 2, True)

def draw_countdown(img, n):
    cx, cy = DISPLAY_W // 2, DISPLAY_H // 2
    
    cv2.circle(img, (cx, cy), 100, C['dark_gray'], 4, cv2.LINE_AA)
    cv2.circle(img, (cx, cy), 100, C['cyan'], 4, cv2.LINE_AA)
    
    text(img, str(n), (cx, cy + 35), 4.0, C['white'], 6, True)

def draw_result(img, images, player, cpu, winner, streak, captured_frame):
    if winner and "You Win" in winner:
        txt, col = "YOU WIN!", C['green']
    elif winner and "Computer" in winner:
        txt, col = "YOU LOSE", C['red']
    else:
        txt, col = "DRAW", C['yellow']
    
    text(img, txt, (DISPLAY_W//2, 60), 2.0, col, 4, True)
    
    card_w, card_h = 220, 280
    capture_w, capture_h = 220, 165
    
    total_w = capture_w + 25 + card_w + 80 + card_w
    start_x = (DISPLAY_W - total_w) // 2
    
    card_y = (DISPLAY_H - card_h) // 2 + 20
    capture_y = card_y + (card_h - capture_h) // 2
    
    if captured_frame is not None:
        cap_x = start_x
        
        fh, fw = captured_frame.shape[:2]
        scale = min(capture_w / fw, capture_h / fh)
        nw, nh = int(fw * scale), int(fh * scale)
        resized = cv2.resize(captured_frame, (nw, nh))
        
        box(img, cap_x, capture_y, capture_w, capture_h, C['card'], C['cyan'])
        
        ox = cap_x + (capture_w - nw) // 2
        oy = capture_y + (capture_h - nh) // 2
        img[oy:oy+nh, ox:ox+nw] = resized
        
        text(img, "YOUR MOVE", (cap_x + capture_w//2, capture_y - 12), 0.5, C['cyan'], 2, True)
    
    player_x = start_x + capture_w + 25
    box(img, player_x, card_y, card_w, card_h, C['card_player'], C['cyan'])
    text(img, "YOU", (player_x + card_w//2, card_y + 30), 0.7, C['cyan'], 2, True)
    
    if player:
        images.draw(img, player, (player_x + card_w//2, card_y + card_h//2 + 10), 110, C['cyan'])
        text(img, player.upper(), (player_x + card_w//2, card_y + card_h - 30), 0.6, C['white'], 2, True)
    else:
        text(img, "?", (player_x + card_w//2, card_y + card_h//2 + 20), 3.0, C['dark_gray'], 4, True)
        text(img, "NO HAND", (player_x + card_w//2, card_y + card_h - 30), 0.5, C['dark_gray'], 2, True)
    
    vs_x = player_x + card_w + 40
    text(img, "VS", (vs_x, DISPLAY_H//2 + 20), 1.2, C['gray'], 3, True)
    
    cpu_x = player_x + card_w + 80
    box(img, cpu_x, card_y, card_w, card_h, C['card_cpu'], C['purple'])
    text(img, "CPU", (cpu_x + card_w//2, card_y + 30), 0.7, C['purple'], 2, True)
    
    if cpu:
        images.draw(img, cpu, (cpu_x + card_w//2, card_y + card_h//2 + 10), 110, C['purple'])
        text(img, cpu.upper(), (cpu_x + card_w//2, card_y + card_h - 30), 0.6, C['white'], 2, True)
    
    if streak > 1:
        text(img, f"STREAK: {streak}", (DISPLAY_W//2, card_y + card_h + 40), 0.8, C['yellow'], 2, True)
    
    text(img, "Press SPACE for next round", (DISPLAY_W//2, DISPLAY_H - 120), 0.6, C['gray'], 2, True)

def draw_quit_button(img):
    btn_w, btn_h = 100, 40
    btn_x = DISPLAY_W - btn_w - 25
    btn_y = 25
    
    return button(img, btn_x, btn_y, btn_w, btn_h, "QUIT", C['btn_quit'], C['btn_quit_hover'])

def put_camera(disp, frame, x, y, w, h):
    fh, fw = frame.shape[:2]
    scale = min(w / fw, h / fh)
    nw, nh = int(fw * scale), int(fh * scale)
    
    resized = cv2.resize(frame, (nw, nh), interpolation=cv2.INTER_LINEAR)
    
    ox, oy = x + (w - nw) // 2, y + (h - nh) // 2
    disp[oy:oy+nh, ox:ox+nw] = resized
    
    return ox, oy, nw, nh

class Hands:
    def __init__(self):
        self.mp = mp.solutions.hands
        self.draw_util = mp.solutions.drawing_utils
        self.detector = self.mp.Hands(
            min_detection_confidence=0.7,
            min_tracking_confidence=0.7,
            max_num_hands=1
        )
    
    def process(self, frame):
        return self.detector.process(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
    
    def draw(self, frame, lm):
        self.draw_util.draw_landmarks(
            frame, lm, self.mp.HAND_CONNECTIONS,
            self.draw_util.DrawingSpec(color=(150, 255, 200), thickness=2, circle_radius=3),
            self.draw_util.DrawingSpec(color=(255, 255, 255), thickness=2)
        )
    
    def gesture(self, lm):
        p = lm.landmark
        
        idx = p[8].y < p[6].y - 0.03
        mid = p[12].y < p[10].y - 0.03
        ring = p[16].y < p[14].y - 0.03
        pinky = p[20].y < p[18].y - 0.03
        
        cnt = sum([idx, mid, ring, pinky])
        
        if idx and mid and not ring and not pinky:
            return "Scissors"
        
        if cnt <= 1:
            return "Rock"
        
        if cnt >= 3:
            return "Paper"
        
        return "Scissors" if idx and mid else "Paper"
    
    def close(self):
        self.detector.close()

def main():
    global MOUSE_CLICKED
    
    cap = cv2.VideoCapture(0)
    cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
    cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
    cap.set(cv2.CAP_PROP_FPS, 60)
    cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
    
    print(f"Camera: {int(cap.get(3))}x{int(cap.get(4))}")
    
    init_bg()
    hands = Hands()
    images = Images()
    images.load()
    
    cv2.namedWindow('RPS', cv2.WINDOW_NORMAL)
    cv2.resizeWindow('RPS', DISPLAY_W, DISPLAY_H)
    cv2.setMouseCallback('RPS', mouse_callback)
    
    state = "ready"
    t_countdown = t_result = 0
    player = cpu = winner = None
    streak = 0
    score = {"player": 0, "computer": 0, "draw": 0}
    captured_frame = None
    
    print("\n[ ROCK PAPER SCISSORS ]")
    print("SPACE: Start | Q: Quit\n")
    
    running = True
    
    while running and cap.isOpened():
        ret, frame = cap.read()
        if not ret:
            break
        
        frame = cv2.flip(frame, 1)
        now = time.time()
        
        result = hands.process(frame)
        gesture = None
        
        disp = BG_CACHE.copy()
        
        if state in ["ready", "countdown"]:
            cam = frame.copy()
            cam = cv2.addWeighted(cam, 0.6, np.full_like(cam, C['bg']), 0.4, 0)
            
            if result.multi_hand_landmarks:
                for lm in result.multi_hand_landmarks:
                    hands.draw(cam, lm)
                    if state == "countdown":
                        gesture = hands.gesture(lm)
                        text(cam, f"[ {gesture} ]", (25, 40), 0.8, C['green'])
            
            rect = put_camera(disp, cam, 150, 120, DISPLAY_W - 300, DISPLAY_H - 240)
            cv2.rectangle(disp, (rect[0]-2, rect[1]-2), (rect[0]+rect[2]+2, rect[1]+rect[3]+2), C['cyan'], 2, cv2.LINE_AA)
        
        if state == "ready":
            draw_header(disp, "ROCK PAPER SCISSORS", "Press SPACE to start!")
            draw_scores(disp, score)
            
            if streak > 1:
                text(disp, f"Streak: {streak}", (DISPLAY_W//2, DISPLAY_H - 120), 0.7, C['yellow'], 2, True)
        
        elif state == "countdown":
            elapsed = now - t_countdown
            cnt = 3 - int(elapsed)
            
            draw_header(disp, "GET READY!", "Show your hand")
            draw_scores(disp, score)
            
            if cnt > 0:
                draw_countdown(disp, cnt)
                player = gesture
            elif elapsed < 3.5:
                text(disp, "SHOW!", (DISPLAY_W//2, DISPLAY_H//2 + 25), 2.5, C['green'], 5, True)
                player = gesture
                
                if captured_frame is None:
                    captured_frame = frame.copy()
            else:
                cpu = random.choice(["Rock", "Paper", "Scissors"])
                
                if player:
                    if player == cpu:
                        winner = "Draw!"
                        score["draw"] += 1
                    elif (player == "Rock" and cpu == "Scissors") or \
                         (player == "Scissors" and cpu == "Paper") or \
                         (player == "Paper" and cpu == "Rock"):
                        winner = "You Win!"
                        score["player"] += 1
                        streak += 1
                    else:
                        winner = "Computer Wins!"
                        score["computer"] += 1
                        streak = 0
                else:
                    winner = "No hand!"
                    streak = 0
                
                state = "result"
                t_result = now
        
        elif state == "result":
            draw_result(disp, images, player, cpu, winner, streak, captured_frame)
            draw_scores(disp, score)
        
        if draw_quit_button(disp):
            print(f"\nFinal: YOU {score['player']} - {score['computer']} CPU")
            running = False
        
        MOUSE_CLICKED = False
        
        cv2.imshow('RPS', disp)
        
        key = cv2.waitKey(1) & 0xFF
        if key == ord('q'):
            print(f"\nFinal: YOU {score['player']} - {score['computer']} CPU")
            running = False
        elif key == ord(' '):
            if state == "ready":
                state = "countdown"
                t_countdown = now
                player = cpu = winner = None
                captured_frame = None
            elif state == "result":
                state = "ready"
                player = cpu = winner = None
                captured_frame = None
        elif key == ord('r'):
            score = {"player": 0, "computer": 0, "draw": 0}
            streak = 0
            print("Score reset")
    
    cap.release()
    cv2.destroyAllWindows()
    hands.close()

if __name__ == "__main__":
    main()
    

시연

시연 영상

소감


이번 프로젝트를 혼자 완성하는 동안 딥러닝과 OpenCV에 대한 이해가 부족해 초반엔 어려움이 많았습니다. OpenCV Haar의 패턴 추출 구조 이해와 얼굴 특징 패턴을 정규화하고 유사도를 정확하게 계산하는 부분에서 어려움이 많았습니다.
하지만 문제를 해결하는 과정에서 생성형 AI와 온라인 자료를 적극적으로 활용했더니 점차 감을 잡을 수 있었고, 실제로 AI 객체 추출 모델을 배울 수 있는 값진 경험이 됐습니다. 한편, 아쉬웠던 점은 지금 프로그램이 얼굴의 정면을 기준으로만 패턴을 분석하다 보니, 측면 얼굴 인식률이 낮거나 아예 인식되지 않았습니다.
다음에는 프로젝트를 진행할 때면 기존 정면으로만 패턴을 분석하는 기능을 확장해 측면, 뒷면 인식뿐만 아니라 OpenCV Haar Cascade뿐만 아니라 Dilb, MediaPipe 등의 모델을 적용하고 싶습니다.
그렇게 하여 다양한 각도나 측면에서도 사용자의 ID를 더 정확하게 인식할 수 있는 개인정보 걱정 없는 보안/감시 프로그램을 만들고 싶습니다.