본문 바로가기

AI/CV

[10/22] 아이펠 리서치 15기 TIL | Shallow focus: 인물사진 모드 구현

반응형

DSLR은 사진을 촬영할 때 피사계 심도(depth of field, DOF)를 얕게 하여 초점이 맞은 피사체를 제외한 배경을 흐리게 만든다.

반면 이와 비슷한 효과를 내게 하는 핸드폰 카메라의 인물사진 모드는 화각이 다른 두 렌즈를 사용하는데, 과정은 아래와 같다.

  1. 일반(광각) 렌즈에서는 배경을 촬영하고
  2. 망원 렌즈에서는 인물을 촬영한 뒷배경을 흐리게 처리한 후
  3. 망원 렌즈의 인물과 적절하게 합성한다.

(물론 핸드폰 인물사진의 아웃포커싱 구현은 DSLR의 방식과는 완전히 다르다.)

 

오늘은 딥러닝을 적용해 핸드폰의 인물사진 모드와 비슷한 결과를 내도록 하는 Shallow focus(흔히 말하는 아웃포커싱의 정확한 표현)를 구현해볼 것이다.

 

아래 깃허브 링크에서 이번 포스팅의 자세한 코드를 확인할 수 있다.https://github.com/choiwonjini/AIFFEL_quest_rs/blob/main/Exploration/Ex04/shallow_focus.ipynb

 

AIFFEL_quest_rs/Exploration/Ex04/shallow_focus.ipynb at main · choiwonjini/AIFFEL_quest_rs

Contribute to choiwonjini/AIFFEL_quest_rs development by creating an account on GitHub.

github.com


학습 목표

  • 딥러닝을 적용하여 핸드폰 인물 사진 모드 따라해보기

학습 내용

  1. 사진 준비
  2. 세그멘테이션으로 사람 분리 (시맨틱 세그멘테이션)
  3. 배경 흐리게 설정
  4. 흐린 배경과 원본 영상 합성

+ Peer review


1. 사진 준비

# cv2: OpenCV 라이브러리로, 실시간 컴퓨터 비전을 목적으로 한 프로그래밍 라이브러리
# numpy(NumPy): 행렬이나 대규모 다차원 배열을 쉽게 처리할 수 있도록 지원하는 라이브러리. 데이터 구조 외에도 수치 계산을 위해 효율적으로 구현된 기능을 제공
# torch: PyTorch. 딥러닝 및 텐서 연산을 위한 라이브러리. 인공지능 모델을 만들거나 불러와 추론하는 도구
# torchvision: 이미지 변환 및 전처리를 위한 torchvision의 transform 모듈
# deeplabv3_resnet101: 사전 학습된 DeepLabV3 모델. 이미지를 분류하도록 학습된 모델
# matplotlib: 파이썬 프로그래밍 언어 및 수학적 확장 NumPy 라이브러리를 활용한 플로팅 라이브러리로, 데이터 시각화 도구

import cv2
import numpy as np
import torch
import torchvision.transforms as T
from torchvision.models.segmentation import deeplabv3_resnet101
import matplotlib.pyplot as plt
# cv2.imread(경로): 경로에 해당하는 이미지 파일을 읽어서 변수에 저장
img_orig = cv2.imread("/content/image.png")

print(f"이미지 크기: {img_orig.shape}")

# cv2.cvtColor(입력 이미지, 색상 변환 코드): 입력 이미지의 색상 채널을 변경
# cv2.COLOR_BGR2RGB: 이미지 색상 채널을 변경 (BGR 형식을 RGB 형식으로 변경)
# plt.imshow(): 저장된 데이터를 이미지의 형식으로 표시, 입력은 RGB(A) 데이터 혹은 2D 스칼라 데이터
# https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.imshow.html
# plt.show(): 현재 열려있는 모든 figure를 표시 (여기서 figure는 이미지, 그래프 등)
# https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.show.html
plt.imshow(cv2.cvtColor(img_orig, cv2.COLOR_BGR2RGB))
plt.show()


2. 세그멘테이션

이미지에서 픽셀 단위로 관심 객체를 추출하는 방법이미지 세그멘테이션(image segmentation)이라고 한다.

 

이미지 세그멘테이션은 모든 픽셀에 라벨(label)을 할당하고, 라벨이 같은 객체들은 "공통적인 특징"을 가진다고 가정한다.

이때 공통 특징은 물리적 의미가 없을 수도 있다. 픽셀이 비슷하게 생겼다는 사실은 인식하지만, 우리가 아는 것처럼 실제 물체 단위로 인식하지 않을 수 있는 것이다.

 

세그멘테이션에는 여러 가지 세부 태스크가 있으며, 태스크에 따라 다양한 기준으로 객체를 추출한다.


시맨틱 세그멘테이션(semantic segmentation)

의미 단위(클래스) 로 픽셀을 구분

  • 무엇인지를 구분하지만, 누가 누구인지는 모름
  • “사람”, “차”, “도로” 등으로 모든 픽셀에 클래스 라벨을 부여
  • 동일한 클래스의 여러 객체를 하나로 취급

예시
→ 이미지에 사람 3명이 있어도 전부 같은 색(‘person’)으로 표시됨


인스턴스 세그멘테이션(Instance segmentation)

객체 단위(인스턴스) 로 픽셀을 구분

  • 시맨틱 + 객체 구분 기능 추가
  • “사람 1”, “사람 2”처럼 같은 클래스 내 개체를 구분

예시
→ 사람 3명이 각각 다른 색으로 표시됨
(모두 ‘person’ 클래스이지만 서로 다른 인스턴스로 구분된다.)


패놉틱 세그멘테이션 (Panoptic Segmentation)

시맨틱 + 인스턴스 세그멘테이션을 통합한 방식

  • 모든 픽셀이 ‘어떤 클래스’이며, ‘어떤 인스턴스’에 속하는지를 함께 표현
  • 배경과 개별 객체 모두 포함

예시
→ 도로·하늘(시맨틱 영역) + 각각의 사람·자동차(인스턴스 영역) 모두 표현


세 개의 세그멘테이션의 예시 사진

 

오늘 적용할 것은 시맨틱 세그멘테이션이다.


2.1. 모델 다운로드 및 전처리

오늘 사용할 모델은 사전 학습된 Deeplab 모델이다.

# 모델 다운로드
model = deeplabv3_resnet101(pretrained=True).eval()

# 사전 학습된 모델을 사용하기 때문에
# 모델의 전처리 방식과 입력 크기 등이 사전 학습에 사용된 것과 동일해야 한다.
transform = T.Compose([
    T.ToPILImage(),
    T.Resize((520, 520)),  # 모델 입력 크기 (고정)
    T.ToTensor(),
])
input_tensor = transform(cv2.cvtColor(img_orig, cv2.COLOR_BGR2RGB)).unsqueeze(0)

2.2. 세그멘테이션

이 모델은 pascalvoc 데이터로 학습됐다.

이미지를 모델에 넣었을 때 어떤 결과가 나오는지 확인해보자.

# 모델에 이미지 입력
with torch.no_grad():
    output = model(input_tensor)["out"][0]
    output_predictions = output.argmax(0).byte().cpu().numpy()

# 원본 크기로 Resize
output_predictions_resized = cv2.resize(output_predictions, (img_orig.shape[1], img_orig.shape[0]), interpolation=cv2.INTER_NEAREST)

#pascalvoc 데이터의 라벨종류
LABEL_NAMES = [
    'background', 'aeroplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus',
    'car', 'cat', 'chair', 'cow', 'diningtable', 'dog', 'horse', 'motorbike',
    'person', 'pottedplant', 'sheep', 'sofa', 'train', 'tv'
]
len(LABEL_NAMES)

plt.imshow(output_predictions_resized, cmap="jet", alpha=0.7)
plt.title("Segmentation Mask (Resized)")
plt.show()

 

이미지가 두 가지 세그먼트로 분류된 것을 볼 수 있다.

 

그렇다면, 두 세그먼트에 어떤 라벨이 할당됐는지 알아보자.

unique_classes = np.unique(output_predictions_resized)
for class_id in unique_classes:
    print(LABEL_NAMES[class_id])
# 출력 결과
background
person

 

배경과 사람이 할당되었다.

물체마다 output에 어떤 색상으로 나타나 있는지 알아보자.

#컬러맵 만들기
colormap = np.zeros((256, 3), dtype=int)
ind = np.arange(256, dtype=int)

for shift in reversed(range(8)):
    for channel in range(3):
        colormap[:, channel] |= ((ind >> channel) & 1) << shift
    ind >>= 3

colormap[:20]  # 생성한 20개의 컬러맵 출력
# 출력 결과
array([[  0,   0,   0],
       [128,   0,   0],
       [  0, 128,   0],
       [128, 128,   0],
       [  0,   0, 128],
       [128,   0, 128],
       [  0, 128, 128],
       [128, 128, 128],
       [ 64,   0,   0],
       [192,   0,   0],
       [ 64, 128,   0],
       [192, 128,   0],
       [ 64,   0, 128],
       [192,   0, 128],
       [ 64, 128, 128],
       [192, 128, 128], # person
       [  0,  64,   0],
       [128,  64,   0],
       [  0, 192,   0],
       [128, 192,   0]])

 

이제, 사람만 표시된 마스크를 만들어보자.

# output의 픽셀 별로 예측된 class가 사람이라면 1(True), 다르다면 0(False)
# 1과 0에 각각 255를 곱하였으므로 사람으로 예측된 픽셀은 255, 그렇지 않은 픽셀은 0
# cmap 값을 변경하면 다른 색상으로 확인이 가능함
seg_map = (output_predictions_resized == 15)  # 클래스 id = 15 (사람)
img_mask = seg_map.astype(np.uint8) * 255  # 255 값으로 변환
color_mask = cv2.applyColorMap(img_mask, cv2.COLORMAP_JET)

plt.imshow(img_mask, cmap='gray')  # 흑백으로 표시
plt.show()

이제 3개의 채널을 가졌던 원본과는 다르게 채널 정보가 사라지고,
아래의 물체가 있는 위치는 1, 그 외에는 0인 배열이 되었다. 
# 마스크의 형태
[
 [ 0 1 1 0 0 0 1 1 0 ],
 [ 1 1 1 1 0 1 1 1 1 ],
 [ 0 1 1 1 1 1 1 1 0 ],
 [ 0 0 1 1 1 1 1 0 0 ],
 [ 0 0 0 1 1 1 0 0 0 ]
]

3. 배경 흐리게 하기

# (20, 20): blurring kernel size.
# 이 값이 커질수록 blur 효과가 커짐
img_orig_blur = cv2.blur(img_orig, (20, 20))

plt.imshow(cv2.cvtColor(img_orig_blur, cv2.COLOR_BGR2RGB))
plt.show()

 

이제 흐려진 이미지에서 세그멘테이션 마스크를 이용해 배경만 추출해보자.

img_mask_color = cv2.cvtColor(img_mask, cv2.COLOR_GRAY2BGR)

# cv2.bitwise_not(): 이미지가 반전된다. 배경이 0 사람이 255 였으나
# 연산을 하고 나면 배경은 255 사람은 0.
img_bg_mask = cv2.bitwise_not(img_mask_color)

# cv2.bitwise_and()을 사용하면 배경만 있는 영상을 얻을 수 있다.
# 0과 어떤 수를 bitwise_and 연산을 해도 0이 되기 때문에
# 사람이 0인 경우에는 사람이 있던 모든 픽셀이 0이 된다. 
# 결국 사람이 사라지고 배경만 남음.
img_bg_blur = cv2.bitwise_and(img_orig_blur, img_bg_mask)
plt.imshow(cv2.cvtColor(img_bg_blur, cv2.COLOR_BGR2RGB))
plt.show()

흐려진 배경 추출 완료


4. 흐린 배경과 원본 이미지 합성

# 세그멘테이션 마스크가 255인 부분만 원본 이미지 값을 가지고 오고
# 아닌 영역은 블러된 이미지 값을 사용한다.
img_concat = np.where(img_mask_color==255, img_orig, img_bg_blur)

plt.imshow(cv2.cvtColor(img_concat, cv2.COLOR_BGR2RGB))
plt.show()

최종 결과물

 

최종 결과물로 피사체를 제외한 배경이 흐려지는 효과를 받은 이미지가 나왔다.

하지만 사진을 보면 몇 가지 문제가 있다.

  1. 인물의 테두리가 불안정해보임
  2. 유재석님이 내리고 있는 손 근처의 배경이 블러 처리가 안 됐음

2번의 경우 내리고 있는 손 근처의 배경까지 모두 피사체로 인식해버린 상황이다.

 

혹시 이미지 때문일까?

다른 이미지로 시도해보자.

 

그 전에, 위의 과정을 함수화해서 가독성과 재사용성을 높였다.

# shallow focus 함수 정의
def shallow_focus(img_orig):
    """
    사람 이미지를 입력받아 인물사진 효과를 적용해 출력하는 함수
    model: deeplabv3_resnet101
    """
    # --- 1. 모델 다운로드 및 전처리 ---
    model = deeplabv3_resnet101(pretrained=True).eval()

    # 사전 학습된 모델을 사용하기 때문에
    # 모델의 전처리 방식과 입력 크기 등이 사전 학습에 사용된 것과 동일해야 한다.
    transform = T.Compose([
        T.ToPILImage(),
        T.Resize((520, 520)),  # 모델 입력 크기 (고정)
        T.ToTensor(),
    ])
    input_tensor = transform(cv2.cvtColor(img_orig, cv2.COLOR_BGR2RGB)).unsqueeze(0)

    # 모델에 이미지 입력
    with torch.no_grad():
        output = model(input_tensor)["out"][0]
        output_predictions = output.argmax(0).byte().cpu().numpy()

    # 원본 크기로 Resize
    output_predictions_resized = cv2.resize(output_predictions, (img_orig.shape[1], img_orig.shape[0]), interpolation=cv2.INTER_NEAREST)

   
    # --- 2. 세그먼트 맵, 마스크 정의 ---
    seg_map = (output_predictions_resized == 15)  # 사람 id
    img_mask = seg_map.astype(np.uint8) * 255  # 255 값으로 변환
    color_mask = cv2.applyColorMap(img_mask, cv2.COLORMAP_JET)


    # --- 3. 배경 흐리게 처리 ---
    img_orig_blur = cv2.blur(img_orig, (20, 20))


    # --- 4. 흐려진 이미지에서 세그멘테이션 마스크를 이용해 배경만 추출 ---
    img_mask_color = cv2.cvtColor(img_mask, cv2.COLOR_GRAY2BGR)

    # 이미지를 반전시켜 배경은 255(흰색) 사람은 0(검정)으로 만든다.
    img_bg_mask = cv2.bitwise_not(img_mask_color)

    # cv2.bitwise_and()을 사용하면 배경만 있는 영상을 얻을 수 있다.
    # 0과 어떤 수를 bitwise_and 연산을 해도 0이 되기 때문에
    # 사람이 0인 경우에는 사람이 있던 모든 픽셀이 0이 되어 사람이 사라지고 배경만 남는다.
    img_bg_blur = cv2.bitwise_and(img_orig_blur, img_bg_mask)

    
    # --- 5. 흐린 배경과 원본 영상 합성 ---
    img_concat = np.where(img_mask_color==255, img_orig, img_bg_blur)


    # --- 6. 최종 이미지 출력 ---
    img_orig = cv2.cvtColor(img_orig, cv2.COLOR_BGR2RGB)
    img_concat = cv2.cvtColor(img_concat, cv2.COLOR_BGR2RGB)

    plt.figure(figsize=(10, 5))
    plt.subplot(1, 2, 1)
    plt.imshow(img_orig)
    plt.title("Original Image")

    plt.subplot(1, 2, 2)
    plt.imshow(img_concat)
    plt.title("Shallow Focus Image") 

    plt.show()
gd = cv2.imread("/content/gd.png")
shallow_focus(gd)

 

다른 이미지로 변환해보니 이번에도 같은 문제가 발생했다.

그렇다면 이미지의 문제는 아닌 것 같다.


문제의 원인

  • 이 세 가지 문제점의 공통된 원인은 블러 처리 오류인데, 이는 블러 처리를 위해 선행한 세그멘테이션의 부정확함 때문이다.
    즉, deeplab3 모델이 생성한 시멘틱 세그멘테이션 마스크가 원인이라고 할 수 있다.

솔루션

  • 깊이 추정 모델로 변경
  • 원래 아웃포커싱의 원리는 "피사체가 무엇인지" 가 아닌 "피사체가 카메라로부터 얼마나 멀리 있는지" 이므로 전자의 방식은 한계를 가지기 때문이다.

매커니즘:

1. 깊이 추정 모델 로드:

  • 기존 deeplab3 모델 대신, 이미지의 깊이를 추정하는 MiDaS 모델을 로드한다.

2. 깊이 맵(Depth Map) 생성:

  • 이미지의 픽셀별 깊이 정보를 담은 depth_map을 생성한다. (가까울수록 높은 값을 갖도록)

3. 깊이 마스크(Depth Mask) 생성:

  • depth_map에 cv2.normalize를 사용해 0~255 범위로 정규화한다.
  • cv2.threshold 함수로 임계값을 정하고, 그 값보다 높은 값을 가지는 픽셀은 225, 낮은 값은 0을 할당한 img_mask를 생성한다.

4. 이미지 합성:

  • 생성된 img_mask를 기반으로 기존 코드와 동일하게 배경 추출, 합성을 진행한다.

Peer Review 간략한 후기

팀원 중 한 분과 Peer review를 진행했다.

팀원의 코드를 보니 나와 다른 점을 몇 가지 발견할 수 있었다.

  1. 함수의 파라미터를 추가해 다양한 기능 제공(출력 이미지의 형태, blur 반전 등)
  2. 더 많은 깊이 추정 모델들 제안

특히 함수화를 더 복잡하게 구현하셨는데 배울 점이 있었다.

 

Peer review는 내 코드를 다시 생각해보고, 개선할 점을 찾아볼 수 있는 의미있는 활동인 것 같다.


회고

저번주부터 CV를 공부해보니 흥미가 생긴 것 같다. 

단계별로 정리하며 과제를 완료하고 문제점을 위한 솔루션도 조사해봤는데, 아이펠 goingdeeper 과정에서 CV를 선택하게 된다면 위에서 언급한 솔루션 뿐만 아니라 더 깊게 공부하며 수정해 볼 예정이다.

 

내일부터는 NLP를 공부한다고 한다. 더 열심히 해야겠다.

반응형