본문 바로가기
기타/Python

영상 속 씬스틸러 찾기: PySceneDetect를 이용한 장면 자동 분할 + DBSCAN을 이용한 시선 클러스터링

by 어멘드 2023. 5. 11.
반응형

0-0. 프로젝트 소개

 '영상 크리에이터를 위한 AI 기반 시청자 반응 분석 및 숏폼 자동 제작 서비스'라는 프로젝트를 진행하고 있다. 유튜브와 같은 영상 시청자의 시선과 표정을 분석하여, 쇼츠나 하이라이트와 같은 숏폼 영상을 제작하기에 적합한 장면과 영역 추출해 내고 자동으로 숏폼 영상을 제작해 주는 서비스이다. 예를 들어 1분 27초 ~ 1분 45초 구간에서 웃음이 지속적으로 감지되었고 특정 객체에 시선이 집중되었다고 하면, 해당 재생 구간, 그중에서도 집중된 객체가 있는 영역만을 잘라 쇼츠 영상을 만드는 것이다.

 지난 포스트에서는 전면 카메라를 사용하여 시청자의 시선과 표정 데이터를 추출하는 방법을 살펴보았다. 이번 포스트에서는 수집한 시선 데이터를 활용하여 장면별 씬스틸러를 찾아보자.

 

0-1. 씬스틸러란?

 직역하면 '장면을 훔치는 사람'이라는 뜻이다. 시청자들의 이목, 시선이 집중되는 영상 속 등장인물 또는 객체를 말한다.

 

0-2. 씬스틸러를 어떻게 찾아낼 수 있을까?

 씬스틸러를 우리말로 바꾸면 '시선강탈자' 정도로 바꿀 수 있다. 따라서 시청자들의 시선 데이터를 활용하여, 가장 많이 시선을 받은 객체를 씬스틸러로 선정해주자. 그런데 긴 영상 속에서 각 장면마다 등장하는 객체는 계속 달라지기 때문에, '각 장면 별로 씬스틸러'를 찾아줄 것이다. 프로세스는 다음과 같다.

 먼저 왼쪽의 긴 원본 영상을 오른쪽의 짧은 장면들로 분할한다. 연결된 프레임의 색조, 밝기, 휘도 차이 등을 계산하여 크게 변화하는 지점을 장면의 전환점으로 판단, 이를 기준으로 분할한다.

원본 영상(좌), 클립 영상(우)

 이제 시선이 집중된 구역을 알아내기 위해 해당 장면에서 수집된 시선 포인트들을 클러스터링한다. 가장 큰 클러스터의 바운딩 박스를 시선 집중 구역으로 선정한다.

시선 클러스터링

 시선이 집중된 구역에 맞게 영상을 크롭하여 씬스틸러 영상을 만든다.

정리하면 아래와 같다.

1. 긴 영상을 작은 장면들로 분할한다.
2. 각 장면 별로 시선 데이터를 클러스터링한다.
3. 클러스터링 결과 가장 높은 밀도의 군집이 위치하는 영역을 잘라낸다.

이제 본격적으로 각 단계를 진행해보자.


 

 

1. 장면 분할하기

 먼저 긴 영상을 여러 개의 장면들로 분할하자. 사람이 모든 영상을 시청한 뒤에 수동으로 장면을 분할할 수도 있지만, '숏폼 자동 생성' 서비스인 만큼 장면 분할 또한 자동으로 분할해 줄 것이다. 장면을 분할하기 위해서는 먼저 장면의 전환점을 선정해야 한다. 이를 Shot Transition Detection라고 한다. 전환점의 기준은 여러 가지가 될 수 있다. 등장하는 객체의 구성이 크게 바뀌는 경우, 프레임을 비교해서 연결되는 프레임이 크게 달라지는 경우 등이 있는데 후자의 방법을 사용할 것이다. 이러한 기능을 지원하는 파이썬 라이브러리 PySceneDetect가 존재하니 이것을 활용하자.

 

1-1. PySceneDetect란?

 PySceneDetect는 "영상의 샷 변경을 감지하고, 영상을 별도의 클립으로 자동으로 분할하기 위한 파이썬 라이브러리"이다. 예를 들어, 페이드인/아웃 효과를 넣어 장면 전환을 하는 경우 전체가 검은색으로 뒤덮인 프레임을 찾아 장면을 분할하는 것이다. 페이드인/아웃과 같은 효과가 없더라도 연결된 두 프레임의 변화량이 임계값 이상인 경우 해당 지점을 장면의 전환점으로 선정하여 장면을 분할할 수 있다. 임계값을 커스텀할 수 있으며 이에 대해서는 이후에 더 자세하게 설명하겠다.

 

GitHub - Breakthrough/PySceneDetect: Python and OpenCV-based scene cut/transition detection program & library.

:movie_camera: Python and OpenCV-based scene cut/transition detection program & library. - GitHub - Breakthrough/PySceneDetect: Python and OpenCV-based scene cut/transition detection program &a...

github.com

 아래의 공식 문서를 통해 사용법 등을 확인할 수 있다.

 

PySceneDetect Manual — PySceneDetect v0.6.1 documentation

 

scenedetect.com

 

1-2. PySceneDetect 설치

 실습 환경은 Google Colab에서 진행한다. 먼저 아래 명령어를 실행하여 해당 라이브러리를 설치한다.

!pip install scenedetect[opencv] --upgrade

 아래와 같은 결과화면이 나온다면 설치에 성공한 것이다.

설치 결과 화면

 

1-3. 원본 영상 업로드

 실습에 사용할 영상은 아래의 뽀빠이 애니메이션 영상이다.

이제 장면을 분할할 원본 영상을 업로드하자. [좌측 메뉴바 > 폴더 아이콘 > 업로드 아이콘]을 클릭하고 원본 영상을 선택해 준다. 영상 업로드가 완료되면 /content 아래에 다음과 같이 영상 파일이 생긴 것을 확인할 수 있다.

영상 업로드 방법 & 완료 후

 

1-4. 영상 불러오기

 이제 colab에 업로드 한 영상을 불러오자. open_video() 함수에 영상이 저장된 경로를 넘겨준다.

from scenedetect import open_video

# 영상 불러오기
video_path = 'Fright to the Finish 1954.mov'
video = open_video(video_path)

 

1-5. Detector 선정

 PySceneDetect에서 지원하는 탐지 알고리즘은 크게 3가지가 있다. 각 알고리즘 별로 특성이 다르기 때문에, 영상에 따라 적절한 알고리즘을 선정하여 사용하여야 한다. 페이드인/아웃 효과가 적용되지 않았으므로 ThresholdDetector는 적절하지 않을 것 같고, 카메라 움직임이 많은 영상도 아니기 때문에 이번 영상에 대해서는 ContentDetector를 사용하였다.

  1. ContentDetector    : 인접한 프레임 간의 HSV 색영역의 픽셀 변화량을 설정된 임계값/점수와 비교하며, 이를 초과하면 장면 분할
  2. ThresholdDetector    : 설정된 강도를 임계값으로 사용하여 평균 픽셀 강도가 이 임계값을 초과하거나 아래로 떨어질 때 트리거되는 컷을 감지
  3. AdaptiveDetector    : 경우에 따라 빠른 카메라 움직임을 더 잘 처리하는 ContentDetector의 two-pass 버전

 

1-6. ContentDetector 객체 생성

 ContentDetector 클래스는 다음과 같이 구성된다. 각 파라미터들을 살펴보자.

class scenedetect.detectors.content_detector.ContentDetector(threshold=27.0, min_scene_len=15, weights=Components(delta_hue=1.0, delta_sat=1.0, delta_lum=1.0, delta_edges=0.0), luma_only=False, kernel_size=None)
  1. threshold (float)
        : 픽셀 변화량 임계값
  2. min_scene_len (int)
       
    : 한 장면을 구성하는 최소 프레임 수
          (한 장면이 1-2초와 같이 너무 짧으면 안 되니까 필요.)
  3. weights (ContentDetector.Components)
        : 
    프레임 점수를 계산할 때 각 구성 요소에 배치할 가중치
  4. luma_only (bool)
        :
    영상의 휘도 채널만 고려할 것인지 여부
          (luma_only=True로 설정하는 것과 weights=ContentDetector.LUMA_ONLY로 설정하는 것은 같다.)
  5. kernel_size (Optional[int])
        : 감지된 가장자리를 확장하기 위한 커널의 크기. 3보다 크거나 같은 홀수 정수여야 함.
          (옵셔널이기 때문에 파라미터를 넘겨주지 않는 경우에는 비디오 해상도를 사용하여 자동으로 설정됨.)

 이 중에서 weights, luma_only, kernel_size를 커스텀하는 데에는 색조, 밝기, 휘도 등에 대한 이해가 요구되기 때문에 그냥 디폴트 설정을 사용하였다. threshold와 min_scene_len 만 적절하게 커스텀해보자.

 

1-6-1. threshold 설정

 threshold는 '픽셀 변화량 임계값'float 타입으로 디폴트 값은 27.0이다. 낮을수록 장면이 더 잘게(여러 개로) 쪼개진다. 영상마다 특징이 다르기 때문에 적절한 threshold 값도 다를 것이다. 만약 결과로 생성된 장면에서 "여기서 한번 더 잘라야 할 것 같은데?"라는 생각이 든다면 임계값을 조금 더 낮추어보면 된다. 반대로 "여기서 왜 잘린거지? 뒷 장면이랑 합쳐져야 할 것 같은데?" 라는 생각이 든다면 임계값을 더 높여보면 된다. 뽀빠이 영상에는 어느 정도의 threshold 값이 적절할지 모르겠어서 직접 값을 조정하면서 여러 번 실행해 보았다. 그 결과 역시 디폴트 값 근처인 30.0 전후로 설정하면 사람이 시청했을 때 기대하는 장면 전환점과 비슷하게 분할이 되었다.

 

1-6-2. min_scene_len 설정

 min_scene_len'한 장면을 구성하는 최소 프레임 수'int 타입으로 디폴트 값은 15이다. 만약 각 장면이 최소 3초는 되도록 하고 싶다면, 3초 동안 재생되는 프레임의 수를 계산해서 넣어주면 된다. 따라서 먼저 영상의 초당 프레임 수(fps)를 확인해야 한다. 영상의 fps는 맥이라면 다음과 같이 QuickTime Player를 통해 확인이 가능하다.

QuickTime Player를 사용하여 FPS 확인

 뽀빠이 영상은 60 fps이다.(1초에 60프레임 재생) 빠른 영상이기 때문에 각 장면이 최소 2.5초는 넘도록 설정하려고 한다. 그렇다면 min_scene_len 의 값은 다음과 같이 150으로 계산할 수 있다.

60fps x 2.5s = 150 frames

이제 설정한 threshold와 min_scene_len으로 ContentDetector를 만든다.

from scenedetect.detectors import ContentDetector

# 디텍터 생성, 임계값 30, 장면 당 최소 프레임 수 150
content_detector = ContentDetector(threshold=30.0, min_scene_len=150)

 

1-7. SceneManager 생성

 이제 디텍터까지 만들었다. 하지만 디텍터는 디텍션을 위한 도구에 불과하다. 해당 도구를 실행할 SceneManager가 필요하다. 씬매니저에 디텍터를 추가한 뒤 씬매니저가 해당 디텍터를 사용하여 디텍션을 수행하는 구조이다. SceneManager 객체를 생성하고 방금 만든 디텍터를 등록해 주자. SceneManager()로 생성 후, add_detector() 메서드에 content_detector를 넘겨주면 된다.

from scenedetect import SceneManager

# Scene Manager 생성
scene_manager = SceneManager()
scene_manager.add_detector(content_detector)

 

1-8. Scene Detect 수행

 이제 SceneManager를 사용하여 디텍션을 수행한다. 디텍션을 수행하는 함수는 detect_scenes()이다. 아까 불러온 video를 넘겨주고 잘 진행되고 있는지 콘솔 창으로 확인하기 위해서 show_progress=True로 설정해 주었다.

# 디텍트 수행
scene_manager.detect_scenes(video, show_progress=True)

 실행 결과 총 2,343개의 프레임을 검사하여 7개의 장면을 탐지하였다.

detect_scenes() 실행 결과

 

1-9. 장면 분할 결과 확인

 7개의 장면으로 분할된 것은 알겠는데, 각 장면이 몇 초부터 몇 초까지인지는 출력되지 않았다. 각 장면 리스트는 씬 매니저의 get_scene_list() 메서드를 통해 얻을 수 있다. 장면 목록을 얻고 각 장면의 시작 시각과 종료 시각을 출력해 보자.

# 장면 분할 결과 출력
scene_list = scene_manager.get_scene_list()
for scene in scene_list:
  start, end = scene
  print(start, "~", end)

 출력 결과를 보면 총 7개 장면의 시작, 끝 시각을 확인할 수 있다. 원본 영상을 확인하면서 적절하게 분할되었는지 확인해 보자.

scene_list 출력 결과

 이것이 잘 분할된 것인지 직접 영상을 보면서 확인해 보자. 아래는 8-21초 구간만 잘라낸 것이다. 보면 대충 3개의 장면 정도로 분할할 수 있을 것 같다.

  1. 뽀빠이 혼자 생각하는 장면
  2. 털보가 혼자 생각하는 장면
  3. 여자랑 뽀빠이랑 같이 앉아있는 장면

8-21초 구간

 

디텍터는 이 8-21초 구간을 다음과 같이 세 개의 장면으로 분할하였다. 8-12초 / 12-17초 / 17-21초. 각 장면을 확인해 보면 우리가 기대한 분할점과 일치하는 것을 확인할 수 있다.

각각 8-12초 / 12-17초 / 17-21초 구간으로 편집한 클립 영상

 

1-10. ffmpeg를 활용한 클립 생성

 위의 3개의 영상은 scene_list를 확인하여 수동으로 영상을 잘라준 것이다. 하지만 이는 너무 번거로운 작업이므로 ffmpeg를 활용하여 해당 구간에 맞게 영상을 자동으로 잘라 저장하는 코드를 추가해 주자.

 split_video_ffmpeg() 함수를 사용한다. 영상이 저장되어 있는 경로 video_path와, 각 구간의 시작, 끝 구간 쌍이 담긴 리스트 scene_list를 넘겨주면 된다.

from scenedetect.video_splitter import split_video_ffmpeg

# 영상 자르기 (파일로 저장)
split_video_ffmpeg(video_path, scene_list, show_progress=True)

 실행 결과 다음과 같이 100%까지 잘 진행되었다. 이제 디렉터리를 확인해 보면 다음과 같이 $VIDEO_NAME-Scene-$SCENE_NUMBER.mp4 형식으로 scene_list에 맞게 구간들로 자른 클립 영상들이 저장된 것을 확인할 수 있다!

split_video_ffmpeg() 실행 결과

 

 

1-11. 각 장면 별 썸네일 생성

 PySceneDetect는 각 장면에 대한 썸네일 생성 기능도 제공한다. 부가적인 기능이지만 한번 사용해 보자. save_images() 함수를 사용한다. 각 장면의 시작과 끝 시각 쌍이 담긴 리스트 scene_list, 그리고 영상 video, 각 장면 당 이미지 개수 num_images, 결과 이미지 파일 이름 image_name_template, 결과를 저장할 디렉터리 이름 output_dir을 넘겨준다.

from scenedetect.scene_manager import save_images

# 썸네일 만들기 (jpg 파일로 저장)
save_images(
   scene_list, # 장면 리스트 [(시작, 끝)]
   video, # 영상
   num_images=1, # 각 장면 당 이미지 개수
   image_name_template='$SCENE_NUMBER', # 결과 이미지 파일 이름
   output_dir='thumbnails') # 결과 디렉토리 이름

 실행 결과 다음과 같이 /thumbnails 아래에 jpg 형식으로 각 장면마다 썸네일이 저장되었다.

각 이미지를 확인해 보면 이렇게 썸네일들이 잘 생성된 것을 볼 수 있다.

2, 3, 4번째 장면에 대한 썸네일 이미지들

 

1-12. 전체 소스 코드

자동 장면 분할 파트의 전체 코드는 아래에서 확인할 수 있다.

 

PySceneDetect를 활용한 Scene Transition Detection

PySceneDetect를 활용한 Scene Transition Detection. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 

 


 

 

2. 시선 클러스터링

  이제 장면 분할은 완료했다. 이제 각 장면 별로 시선이 가장 많이 머문 영역을 구해줄 것이다. 여기에는 클러스터링을 활용한다.

 

2-1. 클러스터링이란?

 클러스터링(Clustering)은 비슷한 특징을 가진 데이터들을 묶어서 하나의 그룹으로 만드는 알고리즘이다. 같은 클러스터에 속한 데이터들은 서로 유사항 특징을 가지고 있다. 응시 위치(x, y)를 기준으로 시선 데이터를 클러스터링 해서 가장 많은 시선을 포함하는 클러스터의 바운딩 박스를 "시선이 가장 많이 머문 영역"으로 선정해 줄 것이다.

 

2-2. 클러스터링 알고리즘 선정

 클러스터링 하는 방법, 즉 클러스터링 알고리즘은 K-means, DBSCAN, Means Shift 등 다양하게 존재한다. 해결하려는 문제의 특성과 알고리즘의 특징을 종합적으로 고려하여 어떤 알고리즘을 사용할지 결정해야 한다. 데이터 분석 분야에서 가장 많이 사용되는 두 개의 클러스터링 알고리즘 K-means와 DBSCAN을 비교해 보자.

  1. K-means   
        : 각 클러스터의 중심점과 각 데이터의 거리를 기반으로 클러스터를 형성하는 알고리즘
        : 클러스터링이 몇 개가 필요한지에 대한 사전 지식이 있을 때 적합
        : 간단하고 빠르며 결과를 해석하기 쉽다는 장점
        : 초기 클러스터 중심값에 따라 결과가 달라지며 이상치에 민감하게 반응한다는 단점
  2. DBSCAN
        : 데이터의 밀도
    를 기반으로 클러스터를 형성하는 알고리즘
        : 클러스터의 개수나 형태를 미리 지정할 필요가 없음
        : 이상치에 강하며, 자동으로 클러스터의 개수를 찾아내며, 노이즈 데이터를 자동으로 구분할 수 있다는 장점
        : 클러스터의 모양이 복잡한 경우에는 성능이 떨어질 수 있다는 단점

다음과 같은 문제의 특성을 고려하여, 최종적으로 DBSCAN 알고리즘을 사용하는 것으로 결정하였다.

1. '가장 많은 시선이 머문 영역'은 일종의 밀도 높은 시선이 위치한 영역을 구하는 것이라고 할 수 있다.
2. 분석 전까지는 씬 스틸러의 개수를 알 수 없다.
3. 여러 사람들의 시선 데이터이기 때문에 이상치가 충분히 존재할 수 있다.
4. 단순 (x, y)의 2차원 데이터이다.

 

2-3. 시선 데이터 생성

많은 시선 데이터가 필요하기 때문에 실제 시선 데이터로 실습하기에는 무리가 있다. 간단하게 더미 데이터를 생성하여 실습해 본다. 우선 좌표계는 아래와 같다. 좌상단이 (0.0, 0.0)이고, 영상 해상도에 관계없이 가장 끝 지점이 1.0이 되도록 정규화한 좌표계이다.

200개의 시선 좌표를 생성해 준다. 클러스터링이 제대로 되려면 점들이 어느 정도는 모여있어야 한다. 단순하게 랜덤한 점을 생성할 경우 클러스터가 이루어지지 않을 가능성이 크다. 따라서 6개의 작은 서브 영역을 지정한 뒤, 각 영역 내에서 10-100개의 랜덤한 점을 생성해 주었다. 그리고 실제 시선 데이터의 노이즈 가능성을 고려하여 임의의 노이즈를 추가해 주었다.

import numpy as np

# 총 200개의 좌표 생성
number_of_points = [20, 10, 20, 10, 100, 40]
x_ranges = [(0.3, 0.7), (0.4, 0.5), (0.2, 0.5), (0.9, 1.0), (0.7, 0.8), (0.2, 0.3)]
y_ranges = [(0.3, 0.9), (0.2, 0.5), (0.2, 0.6), (0.1, 0.2), (0.2, 0.7), (0.1, 0.3)]
points = []

for i in range(6):
  for j in range(number_of_points[i]):
    x = np.random.uniform(low=x_ranges[i][0], high=x_ranges[i][1])
    y = np.random.uniform(low=y_ranges[i][0], high=y_ranges[i][1])
    points.append((x, y))

# 노이즈 추가
noise = np.random.uniform(low=0.0, high=0.15, size=(200, 2))

# 시선 더미 데이터
gaze_data = points + noise
print(gaze_data)

출력된 시선 더미 데이터를 확인해보면 아래와 같다. (x, y) 좌표가 0.0-1.0으로 정규화된 200개의 점이 생성되었다.

 

2-4. 클러스터링 수행

이제 gaze_data에 대해 클러스터링을 수행하자. DBSCAN 생성에 사용되는 주요 파라미터는 두 가지가 있다. eps와 min_samples, 두 파라미터가 형성되는 클러스터의 크기와 밀도를 결정하게 된다. 의미 있는 클러스터를 얻기 위해서는 입력 데이터의 특성을 잘 파악해서 적절한 값을 지정해주어야 한다.

  1. eps (앱실론)   
        : 각 데이터 포인트 주위의 이웃 반경
        : 두 개의 데이터 포인트가 eps 거리 내에 있으면 같은 클러스터로 간주
        : eps를 증가시키면? 더 적지만 더 큰 클러스터가 생성됨
        : eps를 감소시키면? 더 작고 조밀한 여러 개의 클러스터가 생김
  2. min_samples   
        : 
    클러스터를 형성하기 위해 eps 반경 내에 존재해야 하는 최소 포인트 수
        : eps 반경 내의 포인트 수가 min_samples 보다 작으면 해당 포인트는 노이즈 포인트로 간주
        : min_samples를 증가시키면? 더 적지만 더 밀집된 클러스터 형성, 노이즈 필터링에 도움
        : min_samples를 감소시키면? 더 작고 많은 클러스터 형성, 클러스터에 더 많은 노이즈가 포함될 수 있음

 eps 와 min_samples 값을 여러 번 조정하면서 적절한 값을 찾아보자. 전체 시선 포인트의 개수가 200개이므로, min_samples는 5-10%인 10-20개로 고정하고, eps 값도 조정해보면서 실험해본다.

 먼저 min_samples = 10으로 고정하고 eps를 0.05-0.15로 조정했을 때의 결과를 비교해보자. eps=0.05인 경우부터 살펴보면, 너무 작고 조밀한 클러스터가 형성되었다. 빨강, 초록 클러스터의 위치는 괜찮아 보이는데 클러스터의 크기가 조금 더 커졌으면 좋겠다는 생각이 든다. eps=0.10으로 조금 증가시켜보면 확실히 클러스터의 크기가 커졌다. 하지만 노이즈로 볼 수도 있는 포인트들이 꽤 클러스터에 포함되어 있는 것을 볼 수 있다. eps=0.15까지 증가시켜보면, 2개의 클러스터(초록, 빨강)가 합쳐져서 매우 큰 클러스터 1개만 형성되어 클러스터링 되었다고 하기 어려운 결과가 나왔다.

min_samples=10 고정, 각각 eps=0.05 / 0.10 / 0.15

이번에는 min_samples를 조금 늘려 20으로 고정하고 eps를 0.05 - 0.15로 조정했을 때의 결과를 비교해보자. eps=0.05인 경우, 클러스터가 전혀 형성되지 않았다. min_samples는 늘어났지만 eps는 더 작아졌기 때문에 클러스터 형성 조건이 하나도 만족되지 않은 것이다. 다음은 eps=0.10인 경우이다. min_samples=10 & eps=0.10인 경우에서 노이즈가 필터링 된, 조금 더 밀집된 클러스터가 형성되어 가장 이상적인 클러스터가 형성되었다. eps=0.15인 경우를 마지막으로 살펴보면, min_samples=10일 때와 마찬가지로 2개의 클러스터가 합쳐져 매우 큰 클러스터 1개만 형성되었다.

min_samples=20 고정, 각각 eps=0.05 / 0.10 / 0.15

최종적으로 eps = 0.1, min_samples = 20으로 결정하였다. 해당 파라미터를 가지고 DBSCAN 클러스터링을 수행해보자.

from sklearn.cluster import DBSCAN

# DBSCAN 알고리즘 사용하여 시선데이터 클러스터링
dbscan = DBSCAN(eps=0.1, min_samples=20)
labels = dbscan.fit_predict(gaze_data)

# 클러스터 개수 출력
n_clusters = len(set(labels)) - (1 if -1 in labels else 0)
print(f"Number of clusters found: {n_clusters}")

출력 결과를 확인해 보면, 총 2개의 클러스터가 형성되었음을 확인할 수 있다.

 

2-5. 전체 소스 코드

 시선 클러스터링 파트의 전체 소스 코드는 아래에서 확인할 수 있다.

 

DBSCAN을 사용하여 시선 데이터 클러스터링

DBSCAN을 사용하여 시선 데이터 클러스터링. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com

 


 

 

3. 최대 클러스터에 맞추어 영상 크롭하기

 장면 분할, 시선 클러스터링을 마쳤다. 이제 시선 클러스터링 결과를 활용하여 '시선이 가장 집중된 영역'만 크롭해주자.

 

3-1. 최대 클러스터의 바운딩 박스 계산

 여러 개의 클러스터 중 가장 많은 시선 포인트를 포함하는 최대 클러스터를 찾자. 그리고 해당 클러스터를 감싸는 직사각형 영역, 바운딩 박스를 계산하자. 바운딩 박스는 클러스터 내 모든 점을 포함해야 하므로, 다음과 같이 계산할 수 있다.

x = min(클러스터 내 모든 포인트들의 x좌표)
y = min(클러스터 내 모든 포인트들의 y좌표)
width = max(클러스터 내 모든 포인트들의 x좌표) - min(클러스터 내 모든 포인트들의 x좌표)
height = max(클러스터 내 모든 포인트들의 y좌표) - min(클러스터 내 모든 포인트들의 y좌표)

 코드로는 다음과 같이 구현할 수 있다.

# 최대(가장 많은 시선이 포함된) 클러스터의 정보
max_n = 0 # 클러스터에 속하는 시선의 수
xx = 0    # 클러스터 바운딩박스의 x좌표
yy = 0    # 클러스터 바운딩박스의 y좌표
ww = 0    # 클러스터 바운딩박스의 너비
hh = 0    # 클러스터 바운딩박스의 높이

# 라벨 집합
unique_labels = set(labels)

# 최대 클러스터 구하기
for label in unique_labels:
    # 클러스터에 속하지 않는 포인트
    if label == -1:
        continue

    cluster_points = []

    # label번 클러스터에 속한 점만 필터링
    for i in range(200):
      if labels[i] == label:
        cluster_points.append(gaze_data[i])

    # 최대 클러스터 정보 업데이트
    n = len(cluster_points)
    if n > max_n:
      max_n = n

      # 바운딩 박스 계산
      xs = [point[0] for point in cluster_points]
      ys = [point[1] for point in cluster_points]
      xx = np.min(xs)
      yy = np.min(ys)
      ww = np.max(xs) - xx
      hh = np.max(ys) - yy

print(xx, yy, ww, hh)

 계산한 바운딩 박스를 출력하여 확인해보면 아래와 같다. 이것이 잘 계산된 것인지 plot으로 확인해 보자.

 클러스터 별로 다른 색으로 포인트를 표시해 주고 계산한 최대 클러스터의 바운딩 박스를 그린다.

import matplotlib.pyplot as plt
import matplotlib.patches as patches

fig, ax = plt.subplots()

# 최대 클러스터의 바운딩박스 그리기
rect = patches.Rectangle((xx, yy), ww, hh, linewidth=0.1, edgecolor='black', facecolor='none')
ax.add_patch(rect)

# 시선 포인트들 그리기
ax.scatter(gaze_data[:, 0], gaze_data[:, 1], c=labels, cmap='rainbow')
plt.show()

실행 결과 가장 큰 클러스터(초록)에 맞게 바운딩 박스도 잘 계산된 것을 확인할 수 있다.

 

 

3-2. 바운딩 박스에 맞게 영상 크롭

 이제 구한 바운딩 박스에 맞게 영상을 크롭하여 씬스틸러 영상을 만들자. (전체 프로젝트에서는 단순히 클러스터의 바운딩박스만 사용하지 않고 YOLO를 사용하여 Object Detection을 진행, 최대 클러스터와 가장 많이 겹치는 객체를 씬스틸러로 선정하여 '객체'의 바운딩 박스에 맞게 크롭한다.)

 영상 크롭에는 ffmpeg 라이브러리를 사용한다. 크롭할 구역을 x, y, w, h 파라미터들로 지정해 주면 된다. 그런데 지금 우리가 구한 바운딩 박스는 0.0 - 1.0으로 정규화되어 있기 때문에 이대로 넘기면 제대로 크롭이 되지 않는다(매우 작은 영역만 크롭될 것이다). 우리가 의도한 영역을 크롭하기 위해서는 영상의 크기(해상도)를 구하여 x, y, w, h에 모두 곱해주어야 한다.

따라서 먼저 영상의 해상도를 구해보자. 다음과 같이 OpenCV를 이용하여 영상에서 프레임을 따고 해당 프레임의 너비와 높이를 구하면 된다.

import cv2

# 영상의 해상도를 구하는 함수
def get_video_resolution(file_path):
  vid = cv2.VideoCapture(file_path)
  height = vid.get(cv2.CAP_PROP_FRAME_HEIGHT)
  width = vid.get(cv2.CAP_PROP_FRAME_WIDTH)
  return ((width, height))

 이제 이 함수를 활용하여 영상의 해상도에 맞게 바운딩 박스를 재계산하고 그에 맞게 영상을 크롭 해보자. 크롭할 영상은 위에서 분할한 장면 클립 중 4번째 클립이다. 크롭한 영상을 scene_stealer_004.mp4라는 파일명으로 저장한다.

import subprocess

# 입력, 출력 파일 경로
input_file = "Fright to the Finish 1954-Scene-004.mp4"
output_file = "scene_stealer_004.mp4"

# 크롭 파라미터 계산
WIDTH, HEIGHT = get_video_resolution(input_file)
x = xx * WIDTH
y = yy * HEIGHT
w = ww * WIDTH
h = hh * HEIGHT

# 영상을 크롭하는 ffmpeg 커멘드 생성
command = ["ffmpeg", "-i", input_file, "-filter:v", f"crop={w}:{h}:{x}:{y}", "-c:a", "copy", output_file]

# 커멘드 실행
subprocess.run(command)

실행 후 디렉터리를 확인해 보면 아래와 같이 영상이 추가된 것을 볼 수 있다.

 4번째 장면 클립 전체(크롭 X) 영상과 시선 클러스터에 맞게 크롭 된 영상을 비교해보면 아래와 같다. 더미 시선 데이터가 뽀빠이 쪽에 밀집되도록 구성했었는데, 결과를 보면 뽀빠이만 잘 크롭한 것을 볼 수 있다.

크롭 전(좌)과 크롭 후(우) 영상

 

3-3. 씬 스틸러 이미지 추출

 씬 스틸러 이미지는 간단하게 크롭된 영상의 썸네일을 구하는 것으로 대신할 수 있다. 영상 분할 파트에서는 PySceneDetect를 활용하여 구했다면 이번에는 ffmpeg를 사용하여 구해보자.

 영상의 00:01초 지점을 캡처하여 썸네일 이미지를 생성, 004_scene_stealer_thumbnail.jpg라는 파일명으로 저장해 준다.

import os
import subprocess

def generate_thumbnail(input_video_path, output_image_path):
  # 00:00:01초 지점에서 썸네일 이미지 생성
  subprocess.call(['/usr/bin/ffmpeg', '-i', input_video_path, '-ss', '00:00:01', '-vframes', '1', output_image_path])

generate_thumbnail('scene_stealer_004.mp4', '004_scene_stealer_thumbnail.png')

 실행 후 생성된 이미지를 확인해 보면, 다음과 같이 썸네일이 잘 생성된 것을 확인할 수 있다.

씬스틸러 이미지

 

3-4. 전체 소스 코드

 영상 크롭 파트의 전체 소스 코드는 아래에서 확인할 수 있다.

 

바운딩 박스에 맞추어 영상 크롭

바운딩 박스에 맞추어 영상 크롭. GitHub Gist: instantly share code, notes, and snippets.

gist.github.com


레퍼런스

http://scenedetect.com/projects/Manual/en/latest/index.html

https://gongu.copyright.or.kr/gongu/wrt/wrt/view.do?wrtSn=13223157&menuNo=200026

https://jhryu1208.github.io/data/2020/12/26/ML_DBSCAN/

https://stackoverflow.com/questions/7348505/get-dimensions-of-a-video-file

https://pizzathief.oopy.io/dbscan#bab5ada4-c605-423f-a941-97ae070e3634

반응형

댓글