튜토리얼

Twelve Labs API로 손쉬운 비디오 분류하기: ML 학습이 필요 없습니다!

안키트 카레 카레 (Ankit Khare)

개발자는 별도의 머신러닝 모델을 학습시킬 필요 없이, Twelve Labs의 분류(Classification) API를 활용해 정해진 카테고리로 비디오를 손쉽게 분류할 수 있습니다. 이 튜토리얼에서는 비디오 업로드부터 자연어 프롬프트를 사용한 커스텀 분류 기준 정의, 그리고 플라스크(Flask) 앱에 결과를 시각화하는 과정까지 자세히 다룹니다.

개발자는 별도의 머신러닝 모델을 학습시킬 필요 없이, Twelve Labs의 분류(Classification) API를 활용해 정해진 카테고리로 비디오를 손쉽게 분류할 수 있습니다. 이 튜토리얼에서는 비디오 업로드부터 자연어 프롬프트를 사용한 커스텀 분류 기준 정의, 그리고 플라스크(Flask) 앱에 결과를 시각화하는 과정까지 자세히 다룹니다.

목차

No headings found on page

뉴스레터 구독하기

뉴스레터 구독하기

영상 이해 분야의 최신 기술 업데이트, 튜토리얼 및 인사이트를 받아보세요.

영상 이해 분야의 최신 기술 업데이트, 튜토리얼 및 인사이트를 받아보세요.

AI로 영상을 검색하고, 분석하고, 탐색하세요.

2023. 5. 9.

14분

링크 복사하기

비디오 분류란 비디오의 콘텐츠를 기반으로 하나 이상의 미리 정의된 카테고리 또는 라벨을 자동으로 지정하는 프로세스를 의미합니다. 이 작업은 비디오에 나타나는 이벤트, 행동, 사물 또는 기타 특징을 인식하고 이해하기 위해 비디오의 시각적 정보 및 오디오 정보를 분석하는 과정을 포함합니다. 비디오 분류는 컴퓨터 비전 분야의 중요한 연구 영역이며, 비디오 인덱싱, 콘텐츠 기반 비디오 검색, 비디오 추천, 비디오 감시, 인간 행동 인식 등 매우 다양한 실무 분야에 적용되고 있습니다.

과거에는 비디오 분류가 미리 정의된 카테고리나 라벨에 국한되어 이벤트, 행동, 사물 등의 특징을 식별하는 데 초점을 맞추었습니다. 모델을 재학습하고 기준을 업데이트하지 않은 채 분류 기준을 맞춤 설정하는 것은 아주 먼 꿈처럼 보였습니다. 하지만 바로 이 시점에 Twelve Labs의 classification API가 등장하여, 학습 절차에 구애받지 않고 우리의 맞춤 기준에 따라 비디오를 근실시간으로 아주 쉽고 강력하게 분류해 줍니다. 그야말로 판도를 바꾸는 게임 체인저라 할 수 있죠!

Twelve Labs Classification API - 개념 개요

Twelve Labs의 classification API는 각 비디오 내에서 클래스 라벨이 차지하는 시간(총 길이 대비 비율)을 기준으로 인덱싱된 비디오에 라벨을 지정하도록 설계되었습니다. 만약 해당 비율이 50% 미만인 경우, 클래스 라벨은 적용되지 않습니다. 따라서 특히 대용량 비디오를 업로드할 때는 클래스와 프롬프트를 신중하게 설계하는 것이 중요합니다. 이 API 서비스는 개수 제한 없이 원하는 만큼 많은 클래스를 지원할 수 있으며, 하나의 클래스 내에 원하는 만큼의 프롬프트를 추가할 수 있습니다.

예를 들어, 여러분의 반려견인 브루노(Bruno)와 반려묘인 칼라(Karla)가 다양한 장난을 치는 재미있는 비디오 모음집을 가지고 있다고 가정해 봅시다. 이 비디오들을 Twelve Labs 플랫폼에 업로드하고, 사랑스러운 털뭉치 친구들의 유쾌한 탈출극에 딱 맞춘 커스텀 분류 기준을 이렇게 생성할 수 있습니다:

<pre><code class="json">"classes": [
      {
            "name": "Doge_Bruno",
            "prompts": [
                "playing with my dog",
                "my dog doing funny things",
                "dog playing with water"
            ]
        },
        {
            "name": "Kitty_Karla",
            "prompts": [
                "cat jumping",
                "cat playing with toys"
            ]
        }
  ]
  </code></pre>

단 한 번의 API 호출만으로 직접 구축한 분류 기준에 따라 업로드된 비디오들을 쉽게 분류할 수 있습니다. 혹시 몇 가지 프롬프트를 잊었거나 새로운 클래스를 도입하고 싶다면, 해당 JSON에 클래스와 프롬프트를 더 추가하기만 하면 됩니다. 새로운 모델을 학습시키거나 기존 모델을 재학습시킬 필요가 전혀 없어 모든 프로세스가 매우 간편합니다.

분류 결과

빠른 요약

사전 준비 사항: 본 튜토리얼을 매끄럽게 따라오려면, Twelve Labs API 제품군에 가입하고 필요한 패키지들을 설치해 주세요. 기본 개념을 손쉽게 파악하기 위해 첫 번째두 번째 튜토리얼을 먼저 읽어보시는 것을 권장합니다 🤓.

비디오 업로드: 비디오를 Twelve Labs 플랫폼으로 전송하면 간편하게 인덱싱이 완료됩니다. 이를 통해 즉석에서 커스텀 분류 기준을 더하고 콘텐츠를 자유롭게 관리할 수 있습니다! 게다가 머신러닝 모델을 직접 학습시킬 필요조차 없습니다 😆😁😊.

비디오 분류: 이제 본격적으로 즐겨볼 시간입니다! 우리만의 맞춤 클래스들과 각 클래스에 들어갈 매력적인 프롬프트들을 디자인해 보겠습니다. 기준을 모두 정했다면, 지체 없이 바로 가동해 결과를 받아볼 수 있습니다. 막힘없이 곧바로 핵심으로 가 보시죠! 🍿✌️🥳

데모 앱 제작: classification API를 통해 얻은 결과를 활용하고, 컴퓨터의 로컬 폴더에 저장된 비디오에 액세스하는 Flask 기반 앱을 만들어 볼 것입니다. 그런 다음, 세련되게 디자인된 HTML 페이지를 렌더링해 분류 결과를 아주 멋지게 보여주겠습니다 🔍💻🎨.👨‍🎨

사전 준비 사항

첫 번째 튜토리얼에서는 간단한 자연어 쿼리를 사용해 비디오 내에서 특정 순간을 찾는 기본 방법을 다루었습니다. 과정을 복잡하지 않게 진행하기 위해 단 하나의 비디오만 플랫폼에 업로드했으며, 인덱스 생성 및 구성, 태스크 API 정의, 비디오 인덱싱 작업의 기본 모니터링, 그리고 Flask 기반 데모 앱을 만드는 단계별 설명을 제공했습니다.

두 번째 튜토리얼에서는 한 걸음 더 나아가, 여러 검색 쿼리를 조합하여 더욱 정밀하고 타겟팅된 검색을 구현하는 방법을 탐구했습니다. 여러 비디오를 비동기식으로 업로드하고, 여러 개의 인덱스를 생성했으며, 비디오 인덱싱 작업을 모니터링하고 작업 완료 예정 시간 등을 조회할 수 있는 추가 코드를 구현했습니다. 또한 Flask 앱이 여러 비디오를 수용하고 HTML 템플릿을 사용해 보여줄 수 있도록 구성했습니다.

이러한 흐름을 이어가며, 이번 튜토리얼에서는 파이썬의 기본 내장 라이브러리인 concurrent.futures를 사용하여 비디오를 동기식(동시성 구조)으로 업로드해 볼 것입니다. 비디오의 인덱싱 상태를 모니터링하여 CSV 파일에 기록하고, 입력된 분류 기준과 핵심 분류 API 응답 필드들을 HTML 템플릿에 보기 좋게 표현해 결과를 훨씬 직관적으로 이해할 수 있도록 만들겠습니다.

이 글이나 이전 튜토리얼들을 읽는 도중 해결하기 어려운 부분이 생긴다면 언제든 주저하지 말고 문의해 주세요! 저희는 KTX보다 빠른 속도로 공식 Discord 서버를 통해 신속한 지원을 제공하고 있습니다 🚅🏎️⚡️. 이메일 편이 더 편하시다면 이메일로 연락해 주셔도 좋습니다. Twelve Labs는 현재 오픈 베타 단계에 있으므로 편리하게 Twelve Labs 계정을 생성하고 API 대시보드에 접근하여 API 키를 발급받을 수 있습니다. 무상 제공되는 크레딧을 통해 최대 10시간에 달하는 비디오 콘텐츠를 분류해 보실 수 있습니다.

<pre><code class="python">%env API_KEY=<your_API_key>
%env API_URL=https://api.twelvelabs.io/v1.1

!pip install requests
!pip install flask

import os
import requests
import glob
from pprint import pprint


#Retrieve the URL of the API and the API key
API_URL = os.getenv("API_URL")
assert API_URL

API_KEY = os.getenv("API_KEY")
assert API_KEY
</code></pre>

비디오 업로드

인덱스를 생성하고 비디오 업로드를 위해 구성하기:

<pre><code class="python"># Construct the URL of the `/indexes` endpoint
INDEXES_URL = f"{API_URL}/indexes"

# Set the header of the request
default_header = {
    "x-api-key": API_KEY
}

# Define a function to create an index with a given name
def create_index(index_name, index_options, engine):
    # Declare a dictionary named data
    data = {
        "engine_id": engine,
        "index_options": index_options,
        "index_name": index_name,
    }

    # Create an index
    response = requests.post(INDEXES_URL, headers=default_header, json=data)

    # Store the unique identifier of your index
    INDEX_ID = response.json().get('_id')

    # Check if the status code is 201 and print success
    if response.status_code == 201:
        print(f"Status code: {response.status_code} - The request was successful and a new index was created.")
    else:
        print(f"Status code: {response.status_code}")
    pprint(response.json())
    return INDEX_ID


# Create the indexes
index_id_content_classification = create_index(index_name = "insta+tiktok", index_options=["visual", "conversation", "text_in_video", "logo"], engine = "marengo2.5")

# Print the created index IDs
print(f"Created index IDs: {index_id_content_classification}")
</code></pre>
<pre><code class="bash">Status code: 201 - The request was successful and a new index was created.
{'_id': '64544b858b1dd6cde172af77'}
Created index IDs: 64544b858b1dd6cde172af77
</code></pre>

업로드 함수 작성

이번엔 지정한 폴더 내의 모든 비디오 파일을 자동으로 수집하고, 파일명과 동일한 이름으로 플랫폼에 업로드하는 효율적인 코드를 준비했습니다. 파이썬 라이브러리를 통해 동시성 처리를 적극 활용해 동기식처럼 자연스럽게 처리됩니다. 인덱싱을 원하는 비디오들을 단일 폴더에 일괄적으로 넣기만 하면 준비는 끝납니다! 전체 인덱싱 프로세스 속도는 포함된 비디오 중 가장 긴 비디오 길이를 기준으로 그 길이의 대략 40% 정도면 완료됩니다. 나중에 해당 인덱스에 비디오를 계속해서 더 추가하고 싶으신가요? 매우 간단합니다! 사전에 번거롭게 폴더를 새로 구조화할 필요 없이 기존 폴더에 담아주면 됩니다. 이 영리한 로직이 사전에 업로드가 마쳤거나 진행 중인 동일한 이름의 파일 여부를 확인하므로, 중복 처리를 확실하게 피해 줍니다. 엄청나게 명쾌하고 실용적이죠? 😄

<pre><code class="python">import os
import requests
from concurrent.futures import ThreadPoolExecutor

TASKS_URL = f"{API_URL}/tasks"
TASK_ID_LIST = []
video_folder = 'classify'  # folder containing the video files
INDEX_ID = '64544b858b1dd6cde172af77'

def upload_video(file_name):
    # Validate if a video already exists in the index
    task_list_response = requests.get(
        TASKS_URL,
        headers=default_header,
        params={"index_id": INDEX_ID, "filename": file_name},
    )
    if "data" in task_list_response.json():
        task_list = task_list_response.json()["data"]
        if len(task_list) > 0:
            if task_list[0]['status'] == 'ready': 
                print(f"Video '{file_name}' already exists in index {INDEX_ID}")
            else:
                print("task pending or validating")
            return

    # Proceed further to create a new task to index the current video if the video didn't exist in the index already
    print("Entering task creation code for the file: ", file_name)
    
    if file_name.endswith('.mp4'):  # Make sure the file is an MP4 video
        file_path = os.path.join(video_folder, file_name)  # Get the full path of the video file
        with open(file_path, "rb") as file_stream:
            data = {
                "index_id": INDEX_ID,
                "language": "en"
            }
            file_param = [
                ("video_file", (file_name, file_stream, "application/octet-stream")),] #The video will be indexed on the platform using the same name as the video file itself.
            response = requests.post(TASKS_URL, headers=default_header, data=data, files=file_param)
            TASK_ID = response.json().get("_id")
            TASK_ID_LIST.append(TASK_ID)
            # Check if the status code is 201 and print success
            if response.status_code == 201:
                print(f"Status code: {response.status_code} - The request was successful and a new resource was created.")
            else:
                print(f"Status code: {response.status_code}")
            print(f"File name: {file_name}")
            pprint(response.json())
            print("\n")

# Get list of video files
video_files = [f for f in os.listdir(video_folder) if f.endswith('.mp4')]

# Create a ThreadPoolExecutor
with ThreadPoolExecutor() as executor:
    # Use executor to run upload_video in parallel for all video files
    executor.map(upload_video, video_files)
</code></pre>

인덱싱 프로세스 모니터링

업로드 함수처럼, 동시다발적으로 전개되는 모든 백그라운드 태스크의 상태를 놓치지 않고 꼼꼼히 확인하기 위해 모니터링 로직을 유기적으로 설계했습니다. 동시에 인덱싱 중인 각 비디오 파일의 예상 남은 소요 시간 및 구체적인 업로드 퍼센티지를 정돈된 형태의 CSV 파일에 차곡차곡 기록해 줍니다. 이 편리한 함수는 로컬 디렉터리의 모든 파일이 예외 없이 완벽하게 인덱싱을 끝마칠 때까지 부지런히 동작을 지속합니다. 마지막 단계를 마치면, 정밀하게 계산된 총 경과 시간을 초 단위를 기준으로 알기 쉽게 전광판처럼 보여줍니다. 놀랍도록 강력하고 편리한 모니터링 장치입니다.

<pre><code class="python">import time
import csv
from concurrent.futures import ThreadPoolExecutor, as_completed

def monitor_upload_status(task_id):
    TASK_STATUS_URL = f"{API_URL}/tasks/{task_id}"
    while True:
        response = requests.get(TASK_STATUS_URL, headers=default_header)
        STATUS = response.json().get("status")
        if STATUS == "ready":
            return task_id, STATUS
        remain_seconds = response.json().get('process', {}).get('remain_seconds', None)
        upload_percentage = response.json().get('process', {}).get('upload_percentage', None)
        if remain_seconds is not None:
            print(f"Task ID: {task_id}, Remaining seconds: {remain_seconds}, Upload Percentage: {upload_percentage}")
        else:
             print(f"Task ID: {task_id}, Status: {STATUS}")
        time.sleep(10)

# Define starting time
start = time.time()
print("Starting to monitor...")

# Monitor the indexing process for all tasks
with ThreadPoolExecutor() as executor:
    futures = {executor.submit(monitor_upload_status, task_id) for task_id in TASK_ID_LIST}
    with open('upload_status.csv', 'w', newline='') as file:
        writer = csv.writer(file)
        writer.writerow(["Task ID", "Status"])
        for future in as_completed(futures):
            task_id, status = future.result()
            writer.writerow([task_id, status])

# Define ending time
end = time.time()
print("Monitoring finished")
print("Time elapsed (in seconds): ", end - start)
</code></pre>

출력 결과:

<pre><code class="language-plaintext">Starting to monitor...
Monitoring finished
Time elapsed (in seconds):  253.00311
</code></pre>

인덱스 내 모든 비디오 목록 조회

필요한 비디오들이 완벽히 인덱싱되었는지 꼼꼼하게 더블체크하기 위해 인덱스 내의 모든 비디오 목록을 조회해 보겠습니다. 아울러 각 비디오 ID와 고유한 파일 매칭 관계를 손쉽게 추적할 수 있도록 간편한 보조 매핑 리스트를 하나 생성하겠습니다. 이 리스트는 나중에 classification API가 돌려주는 분석 매치 클립(지정한 기준에 완전히 동기화되는 조각 구간들)을 바탕으로 웹상에서 알맞은 실제 파일명을 유연하게 대조해 가져와야 할 때 매우 스마트하게 쓰입니다.

참고로, 업로드된 비디오 총수가 11개이므로 페이지 한계 제한 설정을 20으로 살짝 확장했습니다. 설정되지 않은 API 인터페이스 구조 하에서는 기본값으로 한 페이지당 최대 10개 결과만 나타나기 때문에, 페이지 크기를 키워두지 않으면 11번째 데이터가 유실되거나 video_id_name_list 상에 정상적으로 합쳐지지 못할 여지가 있습니다. 확실한 취합을 위해 한 화면에 전부 들어오도록 여유를 둡시다!

<pre><code class="python"># List all the videos in an index
INDEX_ID='64544b858b1dd6cde172af77'
default_header = {
    "x-api-key": API_KEY
}
# INDEX_ID='64502d238b1dd6cde172a9c5' #movies
# INDEX_ID= '64399bc25b65d57eaecafb35' #lex
INDEXES_VIDEOS_URL = f"{API_URL}/indexes/{INDEX_ID}/videos?page_limit=20"
response = requests.get(INDEXES_VIDEOS_URL, headers=default_header)

response_json = response.json()
pprint(response_json)

video_id_name_list = [{'video_id': video['_id'], 'video_name': video['metadata']['filename']} for video in response_json['data']]

pprint(video_id_name_list)
</code></pre>

출력 결과:

<pre><code class="python">{'data': [{'_id': '64544bb486daab572f3494a0',
           'created_at': '2023-05-05T00:19:33Z',
           'metadata': {'duration': 507.5,
                        'engine_id': 'marengo2.5',
                        'filename': 'JetTila.mp4',
                        'fps': 30,
                        'height': 720,
                        'size': 44891944,
                        'width': 1280},
           'updated_at': '2023-05-05T00:20:04Z'},
          {'_id': '64544bad86daab572f34949f',
           'created_at': '2023-05-05T00:19:32Z',
           'metadata': {'duration': 516.682833,
                        'engine_id': 'marengo2.5',
                        'filename': 'Kylie.mp4',
                        'fps': 23.976023976023978,
                        'height': 720,
                        'size': 37594080,
                        'width': 1280},
           'updated_at': '2023-05-05T00:19:57Z'},
          {'_id': '64544b9286daab572f34949e',
           'created_at': '2023-05-05T00:19:27Z',
           'metadata': {'duration': 34.333333,
                        'engine_id': 'marengo2.5',
                        'filename': 'Oh_my.mp4',
                        'fps': 30,
                        'height': 1280,
                        'size': 10480126,
                        'width': 720},
           'updated_at': '2023-05-05T00:19:30Z'},
          .
					.
					.
					{'_id': '64544b8786daab572f349496',
           'created_at': '2023-05-05T00:19:18Z',
           'metadata': {'duration': 14.333333,
                        'engine_id': 'marengo2.5',
                        'filename': 'cats.mp4',
                        'fps': 30,
                        'height': 1280,
                        'size': 1304438,
                        'width': 720},
           'updated_at': '2023-05-05T00:19:19Z'}],
 'page_info': {'limit_per_page': 20,
               'page': 1,
               'total_duration': 1363.925599,
               'total_page': 1,
               'total_results': 11}}
[{'video_id': '64544bb486daab572f3494a0', 'video_name': 'JetTila.mp4'},
 {'video_id': '64544bad86daab572f34949f', 'video_name': 'Kylie.mp4'},
 {'video_id': '64544b9286daab572f34949e', 'video_name': 'Oh_my.mp4'},
 {'video_id': '64544b8e86daab572f34949c', 'video_name': 'Pitbull.mp4'},
 {'video_id': '64544b9286daab572f34949d', 'video_name': 'She.mp4'},
 {'video_id': '64544b8d86daab572f34949b', 'video_name': 'fun.mp4'},
 {'video_id': '64544b8986daab572f349497', 'video_name': 'Dance.mp4'},
 {'video_id': '64544b8986daab572f349498', 'video_name': 'Jennie.mp4'},
 {'video_id': '64544b8a86daab572f349499', 'video_name': 'McDonald.mp4'},
 {'video_id': '64544b8c86daab572f34949a', 'video_name': 'Orangutan.mp4'},
 {'video_id': '64544b8786daab572f349496', 'video_name': 'cats.mp4'}]
 </code></pre>

비디오 분류

코드 작성으로 넘어가기 위해 핵심 개념을 명료하게 살피고 가겠습니다. 기술적인 코드 구현만을 바란다면 이 단락은 편하게 훑어보고 소스 부분으로 도약해도 좋습니다. Twelve Labs에서 비디오를 정교하게 탐지하기 위해 아래 속성들을 파라미터로 제공해 분류 동작을 긴밀하게 제어할 수 있습니다.

  • classes: 플랫폼이 탐지해내길 바라는 객체나 행위들의 구체적인 명칭과 설명 정의들을 포함한 오브젝트들의 배열입니다. 각 오브젝트는 아래 속성을 가집니다.

  • name: 특정 클래스에 정의해 부여하고 싶은 명칭(문자열 형태)입니다.

  • prompts: 해당 클래스에 내포된 상태들을 구체적으로 묘사하여 제공하는 설명적 표현(문자열 배열 형태)입니다. 플랫폼은 바로 이곳에 제공된 힌트 문구에 온전히 의존하여 비디오 안의 일치 맥락을 포착하고 분류합니다.

  • threshold: 사용자가 지정한 요청 프롬프트들에 대해 플랫폼 모델이 지닌 확신의 수준(Confidence Score)을 기반으로, 어느 정도로 긴밀하고 선별적으로 결과를 추려낼지 제어하는 값입니다. 범위는 최소 0부터 최대 100까지 설정이 가능하고 만약 입력하지 않은 상태에서는 기본 임계치인 75가 자동으로 세팅 적용됩니다. 적합한 범위를 설정해 원하지 않는 부정확한 조각들을 미연에 효과적으로 걷어낼 수 있습니다.

그럼 분류 기준을 정하고 Twelve Labs의 classify API를 호출해 분류 요청을 전송해 보겠습니다. 우선 이번 데모 프로젝트에서는 기본 임계치 설정을 유지하고 관찰하겠습니다:

<pre><code class="python"># Construct the URL of the `/classify/bulk` endpoint
CLASSIFY_BULK_URL = f"{API_URL}/classify/bulk"

# Set the header of the request
headers = {
    "x-api-key": API_KEY
}

# Declare a dictionary named `data`
data =  {
  "conversation_option": "semantic",
  "options": ["visual", "conversation"],
  "index_id" : INDEX_ID,
  "include_clips": False,
  "classes": [
      {
            "name": "BeautyTok",
            "prompts": [
                "Makeup",
                "Skincare",
                "cosmetic products",
                "doing nails",
                "doing hair",
                "DanceTok",
                "dance tutorial",
                "dance competition",
                "dance challenge",
                "dance trend",
                "dancing with friends"
            ]
        },
        {
            "name": "CookTok",
            "prompts": [
                "cooking tutorial",
                "cooking utensils",
                "baking tutorials",
                "recipes",
                "restaurants",
                "food",
                "pasta"
            ]
        },
        {
            "name": "AnimalTok",
            "prompts": [
                "dog",
                "cat",
                "birds",
                "fish",
                "playing with pets",
                "pets doing funny things"
            ]
        },
        {
            "name": "ArtTok",
            "prompts": [
                "handicraft",
                "drawing",
                "graffiti",
                "sketching",
                "digital art",
                "coloring",
                "sketchbook",
                "artwork",
                "artists"
            ]
        }
  ]
}

# Make a classification request
response = requests.post(CLASSIFY_BULK_URL, headers=headers, json=data)
print (f'Status code: {response.status_code}')
pprint(response.json())
duration_data = response.json()
</code></pre>

클래스 내에 설정된 프롬프트들이 매칭되었을 때, 이를 바탕으로 비디오 전체에 최종적인 클래스 라벨이 지정됩니다. 가장 확실한 매치 결과를 끌어내고 정교함을 유지하려면, 연관성이 높고 풍부한 프롬프트를 지정해 주는 것이 중요합니다. 꼭 명심해 두셔야 할 사항은, 비디오 안에서 탐지된 관련 클립들의 총 누적 합산 길이가 비디오 전체 상영 시간 기준 50%를 만족하여 초과하는 수준이어야 해당 클래스 라벨이 공식적으로 지정된다는 점입니다. 이 누적 시간은 프롬프트와 매칭된 개별 비디오 클립들을 결합하여 산출됩니다.

자, 그럼 저희가 수행한 classification API 호출 결과값을 보실까요? 여기서 "duration_ratio"는 비디오 전체 대비 매칭된 클립들의 결합 비율을 계산해 준 값이며, "score"는 예측에 대응하는 플랫폼 모델의 신뢰 수준을 타당한 통계 수치로 표명합니다. "name"은 대조 완료된 클래스 명칭이며, 발견된 매칭 리스트들은 신뢰도가 높은 순서대로 내림차순 정렬되어 출력됩니다.

<pre><code class="python">Status code: 200
{'data': [{'classes': [{'duration_ratio': 1,
                        'name': 'AnimalTok',
                        'score': 95.35111111111111}],
           'video_id': '64544b8786daab572f349496'},
          {'classes': [{'duration_ratio': 1,
                        'name': 'AnimalTok',
                        'score': 95.14666666666668}],
           'video_id': '64544b8e86daab572f34949c'},
          .
			.
			.
          {'classes': [{'duration_ratio': 0.8175611166393244,
                        'name': 'ArtTok',
                        'score': 89.45777777777778}],
           'video_id': '64544b9286daab572f34949d'}],
 'page_info': {'limit_per_page': 10,
               'next_page_token': '',
               'page_expired_at': '2023-05-05T17:37:30Z',
               'prev_page_token': '',
               'total_results': 10}}
 </code></pre>

이번엔 위와 동일한 코드를 호출하되 약간의 변주를 가해 보겠습니다. include_clips 플래그 설정을 True로 지정하는 것이죠. 이렇게 요청하면 각 클래스에 지정한 프롬프트들과 정확히 일치하는 개별적인 실제 매칭 조각(클립)들의 상세 메타데이터까지 온전히 한꺼번에 응답으로 받아내 활용할 수 있게 됩니다.

<pre><code class="python">
# Construct the URL of the `/classify/bulk` endpoint
CLASSIFY_BULK_URL = f"{API_URL}/classify/bulk"

# Set the header of the request
headers = {
    "x-api-key": API_KEY
}

# Declare a dictionary named `data`
data =  {
  "conversation_option": "semantic",
  "options": ["visual", "conversation"],
  "index_id" : INDEX_ID,
  "include_clips": True,
  "classes": [
      {
            "name": "BeautyTok",
            "prompts": [
                "Makeup",
                "Skincare",
                "cosmetic products",
                "doing nails",
                "doing hair",
                "DanceTok",
                "dance tutorial",
                "dance competition",
                "dance challenge",
                "dance trend",
                "dancing with friends"
            ]
        },
        {
            "name": "CookTok",
            "prompts": [
                "cooking tutorial",
                "cooking utensils",
                "baking tutorials",
                "recipes",
                "restaurants",
                "food",
                "pasta"
            ]
        },
        {
            "name": "AnimalTok",
            "prompts": [
                "dog",
                "cat",
                "birds",
                "fish",
                "playing with pets",
                "pets doing funny things"
            ]
        },
        {
            "name": "ArtTok",
            "prompts": [
                "handicraft",
                "drawing",
                "graffiti",
                "sketching",
                "digital art",
                "coloring",
                "sketchbook",
                "artwork",
                "artists"
            ]
        }
  ]
}

# Make a classification request
response = requests.post(CLASSIFY_BULK_URL, headers=headers, json=data)
print (f'Status code: {response.status_code}')
pprint(response.json())
duration_data = response.json()
</code></pre>

출력 결과:

<pre><code class="python">{'data': [{'classes': [{'clips': [{'end': 14,
                                   'option': '',
                                   'prompt': 'cat',
                                   'score': 84.77,
                                   'start': 0},
                                  {'end': 14,
                                   'option': '',
                                   'prompt': 'pets doing funny things',
                                   'score': 83.56,
                                   'start': 8.34375},
                                  {'end': 14,
                                   'option': '',
                                   'prompt': 'playing with pets',
                                   'score': 68.11,
                                   'start': 8.34375},
                                  {'end': 8.34375,
                                   'option': '',
                                   'prompt': 'pets doing funny things',
                                   'score': 58.26,
                                   'start': 0}],
                        'duration_ratio': 1,
                        'name': 'AnimalTok',
                        'score': 95.35111111111111}],
			.
			.
			.	
           'video_id': '64544b8786daab572f349496'},
          {'classes': [{'clips': [{'end': 49.189,
                                   'option': '',
                                   'prompt': 'artists',
                                   'score': 78.14,
                                   'start': 0.55},
                                  {'end': 23.659,
                                   'option': '',
                                   'prompt': 'sketching',
                                   'score': 58.85,
                                   'start': 0.55}],
                        'duration_ratio': 0.8175611166393244,
                        'name': 'ArtTok',
                        'score': 89.45777777777778}],
			.
			.
			.
			'video_id': '64544b9286daab572f34949d'}],
 'page_info': {'limit_per_page': 10,
               'next_page_token': '',
               'page_expired_at': '2023-05-05T17:37:30Z',
               'prev_page_token': '',
               'total_results': 10}}
               </code></pre>

가독성을 위해 출력 일부를 축약했습니다. 이제 출력에 각 비디오의 클립 데이터가 나타나며, 세부적인 시작 및 종료 타임스탬프와 특정 클립 및 연관 프롬프트에 대한 신뢰도 점수가 어떻게 표시되는지 확인해 보세요. 저희는 각 프롬프트에 연결된 해당 분류 옵션(예: visual 및 conversation, 여기서 visual은 시각적 매치, conversation은 대화 매치를 나타냄)을 통합할 수 있도록 API 엔드포인트를 꾸준히 개선해 가고 있습니다.

이제 받아온 JSON 분류 데이터와 앞서 생성한 비디오 ID-이름 데이터 매핑 정보를 로컬 상에서 지속적으로 재활용할 수 있도록 직렬화(pickle)하여 로컬에 저장해 두겠습니다.

<pre><code class="python">import pickle

with open('video_id_name_list.pickle', 'wb') as handle:
    pickle.dump(video_id_name_list, handle, protocol=pickle.HIGHEST_PROTOCOL)

with open('duration_data.pickle', 'wb') as handle:
    pickle.dump(duration_data, handle, protocol=pickle.HIGHEST_PROTOCOL)

with open('clips_data.pickle', 'wb') as handle:
    pickle.dump(clips_data, handle, protocol=pickle.HIGHEST_PROTOCOL)
    </code></pre>

데모 앱 제작

이전 튜토리얼에서 진행했던 방식과 마찬가지로, 저장(직렬화)해 둔 인덱싱 정보들을 웹 페이지로 호스팅하여 매끄럽게 연동해 줄 Flask 기반의 유쾌한 데모 앱을 빌드해보겠습니다. 이 정돈된 데이터들을 바탕으로 로컬 드라이브의 실제 동영상들을 결합하여, 시각적으로 매력적이고 직관적인 웹 기반 분석 대시보드를 그리게 됩니다. 이를 통해 Twelve Labs의 동영상 분류 시스템이 애플리케이션에 가치를 더하고 완성도를 높여주는 과정을 직접 경험하실 수 있을 것입니다.

전체적인 디렉토리 구조는 다음과 같이 명확히 정의됩니다:

<pre><code class="markdown">my_flask_app/
│   app.py
│   sample_notebook.ipynb
└───templates/
│	│   index.html
└───classify/
	│   <your_video_1.mp4><your_video_2.mp4><your_video_3.mp4>
			.
			.
			.
</code></pre>

Flask 앱 코드

이번 가이드에서는 로컬의 특정 폴더에서 비디오 파일을 안전하게 가져와 재생하면서, HTML5 비디오 플레이어를 통해 타임스탬프 기반 의도된 시작지점 조각(클립)을 똑똑하게 지정 구동하는 조절 메커니즘을 살짝 녹였습니다. Flask 애플리케이션 내에 자리한 serve_video 함수가 실행 스크립트와 동일한 소스선상의 classify 디렉토리로부터 비디오 스트림을 웹 인터페이스에 올바르게 송출하고, HTML5 템플릿 상에 선언된 url_for('serve_video', filename=video_mapping[video.video_id]) 속성을 거치면서 브라우저 주소와 기민하게 결합하게 됩니다.

또한, "include_clips" 옵션 하에서 받아온 메타데이터에는 매우 유기적인 디테일이 살아있습니다. 다소 과도하고 분산되어 있을 수 있는 정보들을 한결 깔끔하고 응축된 뷰로 간소화하여 데모의 몰입도를 높이기 위해, 저는 get_top_clips 라는 보조 함수를 정의했습니다. 이 도구는 탐지된 키워드 맥락 중에서 가장 우수한 상위 3개의 고유 프롬프트 클립들만을 명확히 선별해내어, 한층 정돈되고 수준 높은 대시보드 구조를 제공합니다.

아래는 온전한 연동 메커니즘이 집약된 "app.py" 파일의 전체 코드 구성입니다:

<pre><code class="python">from flask import Flask, render_template, send_from_directory
import pickle
import os
from collections import defaultdict

app = Flask(__name__)

# Replace the following dictionaries with your data
with open('video_id_name_list.pickle', 'rb') as handle:
    video_id_name_list = pickle.load(handle)

with open('duration_data.pickle', 'rb') as handle:
    duration_data = pickle.load(handle)

with open('clips_data.pickle', 'rb') as handle:
    clips_data = pickle.load(handle)

VIDEO_DIRECTORY = os.path.join(os.path.dirname(os.path.realpath(__file__)), "classify")

@app.route('/<path:filename>')
def serve_video(filename):
    print(VIDEO_DIRECTORY, filename)
    return send_from_directory(directory=VIDEO_DIRECTORY, path=filename)

def get_top_clips(clips_data, num_clips=3):
    top_clips = defaultdict(list)
    for video in clips_data['data']:
        video_id = video['video_id']
        unique_prompts = set()
        for clip_class in video['classes']:
            for clip in clip_class['clips']:
                if clip['prompt'] not in unique_prompts and len(unique_prompts) < num_clips:
                    top_clips[video_id].append(clip)
                    unique_prompts.add(clip['prompt'])
    return top_clips

@app.route('/')
def home():
    video_id_name_dict = {video['video_id']: video['video_name'] for video in video_id_name_list}
    top_clips = get_top_clips(clips_data)
    return render_template('index.html', video_mapping=video_id_name_dict, duration_data=duration_data, top_clips=top_clips)


if __name__ == '__main__':
    app.run(debug=True)
</code></pre>

HTML 템플릿

아래는 Jinja2 템플릿 문법을 사용하여, 앞서 직렬화해 전달한 분석 데이터 세트를 화면상에 기민하게 반복 순회하며 표현하도록 짜인 HTML 명세서입니다. 이 마크업 단락은 로컬에 보관해 둔 비디오의 상영 구획을 끌어내고, 설계한 기준에 매칭된 분류 전경을 시각적 게이지바와 함께 미려하게 연출해 줍니다.

<pre><code class="language-html"><!doctype html>
<html lang="en">
<head>
    <link rel="shortcut icon" href="#" />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Classification Output</title>
    <style>
        body {
            background-color: #f0e7cc;
            font-family: Arial, sans-serif;
        }
        .container {
            width: 80%;
            margin: 0 auto;
            padding-bottom: 30px;
        }
        video {
            display: block;
            margin: 0 auto;
        }
        .progress-bar {
            width: 20%;
            height: 20px;
            background-color: #f0f0f0;
            margin-bottom: 10px;
            display: inline-block;
            vertical-align: middle;
            margin-left: 10px;
        }
        .progress {
            height: 100%;
            background-color: #4caf50;
        }
        .score {
            display: inline-block;
            vertical-align: middle;
            font-weight: bold;
        }
        .video-category {
            padding-bottom: 30px;
            margin-bottom: 20px;
        }
        button {
            background-color: #008CBA;
            border: none;
            color: white;
            padding: 8px 16px;
            text-align: center;
            text-decoration: none;
            display: inline-block;
            font-size: 14px;
            margin: 4px 2px;
            cursor: pointer;
            border-radius: 4px;
            box-shadow: 0 1px 2px rgba(0,0,0,0.2);
            transition: background-color 0.2s ease;
        }
        button:hover {
            background-color: #006494;
        }
        h1 {
            color: #070707;
            text-align: center;
            background-color: #cb6a16; /* Change the background color to your desired color */
            padding: 10px;
            margin-bottom: 20px;  
        }
        .clips-container {
            text-align: center;
        }

    </style>
    <script>
        function playClip(videoPlayer, start, end) {
            videoPlayer.currentTime = start;
            videoPlayer.play();
            videoPlayer.ontimeupdate = function () {
                if (videoPlayer.currentTime >= end) {
                    videoPlayer.pause();
                }
            };
        }
    </script>
</head>
<body>
    <div class="container">
        <h1>Video Classification</h1>
        {% for video in duration_data.data %}
            {% set video_id = video.video_id %}
            {% set video_path = video_mapping[video_id] %}
            <div class="video-category">
                <h2>{{ video.classes[0].name }}</h2>
                <p>Video file: {{video_path}}</p>
                <div>
                    <span class="score">Score: {{ '%.2f'|format(video.classes[0].score) }}</span>
                    <div class="progress-bar">
                        <div class="progress" style="width: {{ video.classes[0].score }}%;"></div>
                    </div>
                </div>
                <div>
                    <span class="score">Duration: {{ '%.2f%%'|format(video.classes[0].duration_ratio * 100) }}</span>
                    <div class="progress-bar">
                        <div class="progress" style="width: {{ video.classes[0].duration_ratio * 100 }}%;"></div>
                    </div>
                </div>
                <video id="video-{{ video_id }}" width="480" height="320" controls>
                    <source src="{{ url_for('serve_video', filename=video_path) }}" type="video/mp4">
                    Your browser does not support the video tag.
                </video>
                <div class="clips-container">
                    <h3>Sample Clips</h3>
                    {% for clip in top_clips[video_id] %}
                    <button onclick="playClip(document.getElementById('video-{{ video_id }}'), {{ clip.start }}, {{ clip.end }})">Clip: {{ clip.prompt }} | Start {{ '%.2f'|format(clip.start) }}s - End {{ '%.2f'|format(clip.end) }}s - Score {{ '%.2f'|format(clip.score) }}</button>
                    {% endfor %}
            </div>
            <br /><br />
        {% endfor %}
    </div>
</body>
</html>
</code></pre>

Flask 앱 구동하기

훌륭합니다! 이제 Jupyter Notebook의 마지막 셀을 실행해 방금 설정한 Flask 앱을 정상 론칭시켜 보시죠:

<pre><code class="python">%run app.py
</code></pre>

모든 흐름이 예상대로 문제없이 완벽하게 귀결되는 것을 확인할 수 있을 것입니다 😊:

로컬 주소 링크 http://127.0.0.1:5000에 직접 진입해 주시면 다음과 같은 미려한 최종 연출 화면을 영접하게 됩니다:

본 완독 가이드에서 쓰인 모든 파이썬 스크립트 소스와 Jupyter Notebook의 전체 원형을 한눈에 살펴보실 수 있는 통합 보관 주소는 다음과 같습니다 - https://tinyurl.com/classifyNotebook

새로운 가능성을 실험해볼 준비가 되셨나요?

다음과 같은 흥미진진한 구상을 여러분의 비즈니스 도구에 도입해 보세요:

  1. 단순히 상위 3개의 힌트 샘플 단락만을 거르는 데 그치지 않고, 페이지네이션(Pagination) 및 지연 로딩(Lazy Loading) 기법을 멋지게 탑재하여 동영상에서 포착된 수많은 의미 조각 클립들을 빈틈없이 정방향 화면에 연동해 보세요. 이를 통해 사용자는 훨씬 넓은 반경의 카테고리화 대조군을 가시적으로 면밀히 심사하고 분석 전경을 전방위적으로 조망해낼 수 있습니다.

  2. 키워드 및 프롬프트의 의미 구성 방식(Short/Medium/Long-Tail)을 다변화해 보거나 임계치 설정을 유연하게 조절해가며 발견되는 현상 정보를 다채롭게 수집해 보세요. 얻어낸 귀중한 지식 통찰을 저희 Discord 미팅 채널을 통해 전 세계 멀티모달 기술 매니아 동료들과 함께 나누는 멋진 지적 축제를 누려보시길 권합니다.

마치며

앞으로 공개될 흥미진진한 소식들을 기대해 주시기 바랍니다! 아직 탐조등을 켜고 합류하지 않으셨다면, 멀티모달 인공지능의 개척 트렌드를 함께 호흡하고 유익을 도모하는 활기찬 Discord 커뮤니티의 문을 두드려 오손도손 이야기꽃을 피워보시길 진심으로 고대합니다.

비디오 분류란 비디오의 콘텐츠를 기반으로 하나 이상의 미리 정의된 카테고리 또는 라벨을 자동으로 지정하는 프로세스를 의미합니다. 이 작업은 비디오에 나타나는 이벤트, 행동, 사물 또는 기타 특징을 인식하고 이해하기 위해 비디오의 시각적 정보 및 오디오 정보를 분석하는 과정을 포함합니다. 비디오 분류는 컴퓨터 비전 분야의 중요한 연구 영역이며, 비디오 인덱싱, 콘텐츠 기반 비디오 검색, 비디오 추천, 비디오 감시, 인간 행동 인식 등 매우 다양한 실무 분야에 적용되고 있습니다.

과거에는 비디오 분류가 미리 정의된 카테고리나 라벨에 국한되어 이벤트, 행동, 사물 등의 특징을 식별하는 데 초점을 맞추었습니다. 모델을 재학습하고 기준을 업데이트하지 않은 채 분류 기준을 맞춤 설정하는 것은 아주 먼 꿈처럼 보였습니다. 하지만 바로 이 시점에 Twelve Labs의 classification API가 등장하여, 학습 절차에 구애받지 않고 우리의 맞춤 기준에 따라 비디오를 근실시간으로 아주 쉽고 강력하게 분류해 줍니다. 그야말로 판도를 바꾸는 게임 체인저라 할 수 있죠!

Twelve Labs Classification API - 개념 개요

Twelve Labs의 classification API는 각 비디오 내에서 클래스 라벨이 차지하는 시간(총 길이 대비 비율)을 기준으로 인덱싱된 비디오에 라벨을 지정하도록 설계되었습니다. 만약 해당 비율이 50% 미만인 경우, 클래스 라벨은 적용되지 않습니다. 따라서 특히 대용량 비디오를 업로드할 때는 클래스와 프롬프트를 신중하게 설계하는 것이 중요합니다. 이 API 서비스는 개수 제한 없이 원하는 만큼 많은 클래스를 지원할 수 있으며, 하나의 클래스 내에 원하는 만큼의 프롬프트를 추가할 수 있습니다.

예를 들어, 여러분의 반려견인 브루노(Bruno)와 반려묘인 칼라(Karla)가 다양한 장난을 치는 재미있는 비디오 모음집을 가지고 있다고 가정해 봅시다. 이 비디오들을 Twelve Labs 플랫폼에 업로드하고, 사랑스러운 털뭉치 친구들의 유쾌한 탈출극에 딱 맞춘 커스텀 분류 기준을 이렇게 생성할 수 있습니다:

<pre><code class="json">"classes": [
      {
            "name": "Doge_Bruno",
            "prompts": [
                "playing with my dog",
                "my dog doing funny things",
                "dog playing with water"
            ]
        },
        {
            "name": "Kitty_Karla",
            "prompts": [
                "cat jumping",
                "cat playing with toys"
            ]
        }
  ]
  </code></pre>

단 한 번의 API 호출만으로 직접 구축한 분류 기준에 따라 업로드된 비디오들을 쉽게 분류할 수 있습니다. 혹시 몇 가지 프롬프트를 잊었거나 새로운 클래스를 도입하고 싶다면, 해당 JSON에 클래스와 프롬프트를 더 추가하기만 하면 됩니다. 새로운 모델을 학습시키거나 기존 모델을 재학습시킬 필요가 전혀 없어 모든 프로세스가 매우 간편합니다.

분류 결과

빠른 요약

사전 준비 사항: 본 튜토리얼을 매끄럽게 따라오려면, Twelve Labs API 제품군에 가입하고 필요한 패키지들을 설치해 주세요. 기본 개념을 손쉽게 파악하기 위해 첫 번째두 번째 튜토리얼을 먼저 읽어보시는 것을 권장합니다 🤓.

비디오 업로드: 비디오를 Twelve Labs 플랫폼으로 전송하면 간편하게 인덱싱이 완료됩니다. 이를 통해 즉석에서 커스텀 분류 기준을 더하고 콘텐츠를 자유롭게 관리할 수 있습니다! 게다가 머신러닝 모델을 직접 학습시킬 필요조차 없습니다 😆😁😊.

비디오 분류: 이제 본격적으로 즐겨볼 시간입니다! 우리만의 맞춤 클래스들과 각 클래스에 들어갈 매력적인 프롬프트들을 디자인해 보겠습니다. 기준을 모두 정했다면, 지체 없이 바로 가동해 결과를 받아볼 수 있습니다. 막힘없이 곧바로 핵심으로 가 보시죠! 🍿✌️🥳

데모 앱 제작: classification API를 통해 얻은 결과를 활용하고, 컴퓨터의 로컬 폴더에 저장된 비디오에 액세스하는 Flask 기반 앱을 만들어 볼 것입니다. 그런 다음, 세련되게 디자인된 HTML 페이지를 렌더링해 분류 결과를 아주 멋지게 보여주겠습니다 🔍💻🎨.👨‍🎨

사전 준비 사항

첫 번째 튜토리얼에서는 간단한 자연어 쿼리를 사용해 비디오 내에서 특정 순간을 찾는 기본 방법을 다루었습니다. 과정을 복잡하지 않게 진행하기 위해 단 하나의 비디오만 플랫폼에 업로드했으며, 인덱스 생성 및 구성, 태스크 API 정의, 비디오 인덱싱 작업의 기본 모니터링, 그리고 Flask 기반 데모 앱을 만드는 단계별 설명을 제공했습니다.

두 번째 튜토리얼에서는 한 걸음 더 나아가, 여러 검색 쿼리를 조합하여 더욱 정밀하고 타겟팅된 검색을 구현하는 방법을 탐구했습니다. 여러 비디오를 비동기식으로 업로드하고, 여러 개의 인덱스를 생성했으며, 비디오 인덱싱 작업을 모니터링하고 작업 완료 예정 시간 등을 조회할 수 있는 추가 코드를 구현했습니다. 또한 Flask 앱이 여러 비디오를 수용하고 HTML 템플릿을 사용해 보여줄 수 있도록 구성했습니다.

이러한 흐름을 이어가며, 이번 튜토리얼에서는 파이썬의 기본 내장 라이브러리인 concurrent.futures를 사용하여 비디오를 동기식(동시성 구조)으로 업로드해 볼 것입니다. 비디오의 인덱싱 상태를 모니터링하여 CSV 파일에 기록하고, 입력된 분류 기준과 핵심 분류 API 응답 필드들을 HTML 템플릿에 보기 좋게 표현해 결과를 훨씬 직관적으로 이해할 수 있도록 만들겠습니다.

이 글이나 이전 튜토리얼들을 읽는 도중 해결하기 어려운 부분이 생긴다면 언제든 주저하지 말고 문의해 주세요! 저희는 KTX보다 빠른 속도로 공식 Discord 서버를 통해 신속한 지원을 제공하고 있습니다 🚅🏎️⚡️. 이메일 편이 더 편하시다면 이메일로 연락해 주셔도 좋습니다. Twelve Labs는 현재 오픈 베타 단계에 있으므로 편리하게 Twelve Labs 계정을 생성하고 API 대시보드에 접근하여 API 키를 발급받을 수 있습니다. 무상 제공되는 크레딧을 통해 최대 10시간에 달하는 비디오 콘텐츠를 분류해 보실 수 있습니다.

<pre><code class="python">%env API_KEY=<your_API_key>
%env API_URL=https://api.twelvelabs.io/v1.1

!pip install requests
!pip install flask

import os
import requests
import glob
from pprint import pprint


#Retrieve the URL of the API and the API key
API_URL = os.getenv("API_URL")
assert API_URL

API_KEY = os.getenv("API_KEY")
assert API_KEY
</code></pre>

비디오 업로드

인덱스를 생성하고 비디오 업로드를 위해 구성하기:

<pre><code class="python"># Construct the URL of the `/indexes` endpoint
INDEXES_URL = f"{API_URL}/indexes"

# Set the header of the request
default_header = {
    "x-api-key": API_KEY
}

# Define a function to create an index with a given name
def create_index(index_name, index_options, engine):
    # Declare a dictionary named data
    data = {
        "engine_id": engine,
        "index_options": index_options,
        "index_name": index_name,
    }

    # Create an index
    response = requests.post(INDEXES_URL, headers=default_header, json=data)

    # Store the unique identifier of your index
    INDEX_ID = response.json().get('_id')

    # Check if the status code is 201 and print success
    if response.status_code == 201:
        print(f"Status code: {response.status_code} - The request was successful and a new index was created.")
    else:
        print(f"Status code: {response.status_code}")
    pprint(response.json())
    return INDEX_ID


# Create the indexes
index_id_content_classification = create_index(index_name = "insta+tiktok", index_options=["visual", "conversation", "text_in_video", "logo"], engine = "marengo2.5")

# Print the created index IDs
print(f"Created index IDs: {index_id_content_classification}")
</code></pre>
<pre><code class="bash">Status code: 201 - The request was successful and a new index was created.
{'_id': '64544b858b1dd6cde172af77'}
Created index IDs: 64544b858b1dd6cde172af77
</code></pre>

업로드 함수 작성

이번엔 지정한 폴더 내의 모든 비디오 파일을 자동으로 수집하고, 파일명과 동일한 이름으로 플랫폼에 업로드하는 효율적인 코드를 준비했습니다. 파이썬 라이브러리를 통해 동시성 처리를 적극 활용해 동기식처럼 자연스럽게 처리됩니다. 인덱싱을 원하는 비디오들을 단일 폴더에 일괄적으로 넣기만 하면 준비는 끝납니다! 전체 인덱싱 프로세스 속도는 포함된 비디오 중 가장 긴 비디오 길이를 기준으로 그 길이의 대략 40% 정도면 완료됩니다. 나중에 해당 인덱스에 비디오를 계속해서 더 추가하고 싶으신가요? 매우 간단합니다! 사전에 번거롭게 폴더를 새로 구조화할 필요 없이 기존 폴더에 담아주면 됩니다. 이 영리한 로직이 사전에 업로드가 마쳤거나 진행 중인 동일한 이름의 파일 여부를 확인하므로, 중복 처리를 확실하게 피해 줍니다. 엄청나게 명쾌하고 실용적이죠? 😄

<pre><code class="python">import os
import requests
from concurrent.futures import ThreadPoolExecutor

TASKS_URL = f"{API_URL}/tasks"
TASK_ID_LIST = []
video_folder = 'classify'  # folder containing the video files
INDEX_ID = '64544b858b1dd6cde172af77'

def upload_video(file_name):
    # Validate if a video already exists in the index
    task_list_response = requests.get(
        TASKS_URL,
        headers=default_header,
        params={"index_id": INDEX_ID, "filename": file_name},
    )
    if "data" in task_list_response.json():
        task_list = task_list_response.json()["data"]
        if len(task_list) > 0:
            if task_list[0]['status'] == 'ready': 
                print(f"Video '{file_name}' already exists in index {INDEX_ID}")
            else:
                print("task pending or validating")
            return

    # Proceed further to create a new task to index the current video if the video didn't exist in the index already
    print("Entering task creation code for the file: ", file_name)
    
    if file_name.endswith('.mp4'):  # Make sure the file is an MP4 video
        file_path = os.path.join(video_folder, file_name)  # Get the full path of the video file
        with open(file_path, "rb") as file_stream:
            data = {
                "index_id": INDEX_ID,
                "language": "en"
            }
            file_param = [
                ("video_file", (file_name, file_stream, "application/octet-stream")),] #The video will be indexed on the platform using the same name as the video file itself.
            response = requests.post(TASKS_URL, headers=default_header, data=data, files=file_param)
            TASK_ID = response.json().get("_id")
            TASK_ID_LIST.append(TASK_ID)
            # Check if the status code is 201 and print success
            if response.status_code == 201:
                print(f"Status code: {response.status_code} - The request was successful and a new resource was created.")
            else:
                print(f"Status code: {response.status_code}")
            print(f"File name: {file_name}")
            pprint(response.json())
            print("\n")

# Get list of video files
video_files = [f for f in os.listdir(video_folder) if f.endswith('.mp4')]

# Create a ThreadPoolExecutor
with ThreadPoolExecutor() as executor:
    # Use executor to run upload_video in parallel for all video files
    executor.map(upload_video, video_files)
</code></pre>

인덱싱 프로세스 모니터링

업로드 함수처럼, 동시다발적으로 전개되는 모든 백그라운드 태스크의 상태를 놓치지 않고 꼼꼼히 확인하기 위해 모니터링 로직을 유기적으로 설계했습니다. 동시에 인덱싱 중인 각 비디오 파일의 예상 남은 소요 시간 및 구체적인 업로드 퍼센티지를 정돈된 형태의 CSV 파일에 차곡차곡 기록해 줍니다. 이 편리한 함수는 로컬 디렉터리의 모든 파일이 예외 없이 완벽하게 인덱싱을 끝마칠 때까지 부지런히 동작을 지속합니다. 마지막 단계를 마치면, 정밀하게 계산된 총 경과 시간을 초 단위를 기준으로 알기 쉽게 전광판처럼 보여줍니다. 놀랍도록 강력하고 편리한 모니터링 장치입니다.

<pre><code class="python">import time
import csv
from concurrent.futures import ThreadPoolExecutor, as_completed

def monitor_upload_status(task_id):
    TASK_STATUS_URL = f"{API_URL}/tasks/{task_id}"
    while True:
        response = requests.get(TASK_STATUS_URL, headers=default_header)
        STATUS = response.json().get("status")
        if STATUS == "ready":
            return task_id, STATUS
        remain_seconds = response.json().get('process', {}).get('remain_seconds', None)
        upload_percentage = response.json().get('process', {}).get('upload_percentage', None)
        if remain_seconds is not None:
            print(f"Task ID: {task_id}, Remaining seconds: {remain_seconds}, Upload Percentage: {upload_percentage}")
        else:
             print(f"Task ID: {task_id}, Status: {STATUS}")
        time.sleep(10)

# Define starting time
start = time.time()
print("Starting to monitor...")

# Monitor the indexing process for all tasks
with ThreadPoolExecutor() as executor:
    futures = {executor.submit(monitor_upload_status, task_id) for task_id in TASK_ID_LIST}
    with open('upload_status.csv', 'w', newline='') as file:
        writer = csv.writer(file)
        writer.writerow(["Task ID", "Status"])
        for future in as_completed(futures):
            task_id, status = future.result()
            writer.writerow([task_id, status])

# Define ending time
end = time.time()
print("Monitoring finished")
print("Time elapsed (in seconds): ", end - start)
</code></pre>

출력 결과:

<pre><code class="language-plaintext">Starting to monitor...
Monitoring finished
Time elapsed (in seconds):  253.00311
</code></pre>

인덱스 내 모든 비디오 목록 조회

필요한 비디오들이 완벽히 인덱싱되었는지 꼼꼼하게 더블체크하기 위해 인덱스 내의 모든 비디오 목록을 조회해 보겠습니다. 아울러 각 비디오 ID와 고유한 파일 매칭 관계를 손쉽게 추적할 수 있도록 간편한 보조 매핑 리스트를 하나 생성하겠습니다. 이 리스트는 나중에 classification API가 돌려주는 분석 매치 클립(지정한 기준에 완전히 동기화되는 조각 구간들)을 바탕으로 웹상에서 알맞은 실제 파일명을 유연하게 대조해 가져와야 할 때 매우 스마트하게 쓰입니다.

참고로, 업로드된 비디오 총수가 11개이므로 페이지 한계 제한 설정을 20으로 살짝 확장했습니다. 설정되지 않은 API 인터페이스 구조 하에서는 기본값으로 한 페이지당 최대 10개 결과만 나타나기 때문에, 페이지 크기를 키워두지 않으면 11번째 데이터가 유실되거나 video_id_name_list 상에 정상적으로 합쳐지지 못할 여지가 있습니다. 확실한 취합을 위해 한 화면에 전부 들어오도록 여유를 둡시다!

<pre><code class="python"># List all the videos in an index
INDEX_ID='64544b858b1dd6cde172af77'
default_header = {
    "x-api-key": API_KEY
}
# INDEX_ID='64502d238b1dd6cde172a9c5' #movies
# INDEX_ID= '64399bc25b65d57eaecafb35' #lex
INDEXES_VIDEOS_URL = f"{API_URL}/indexes/{INDEX_ID}/videos?page_limit=20"
response = requests.get(INDEXES_VIDEOS_URL, headers=default_header)

response_json = response.json()
pprint(response_json)

video_id_name_list = [{'video_id': video['_id'], 'video_name': video['metadata']['filename']} for video in response_json['data']]

pprint(video_id_name_list)
</code></pre>

출력 결과:

<pre><code class="python">{'data': [{'_id': '64544bb486daab572f3494a0',
           'created_at': '2023-05-05T00:19:33Z',
           'metadata': {'duration': 507.5,
                        'engine_id': 'marengo2.5',
                        'filename': 'JetTila.mp4',
                        'fps': 30,
                        'height': 720,
                        'size': 44891944,
                        'width': 1280},
           'updated_at': '2023-05-05T00:20:04Z'},
          {'_id': '64544bad86daab572f34949f',
           'created_at': '2023-05-05T00:19:32Z',
           'metadata': {'duration': 516.682833,
                        'engine_id': 'marengo2.5',
                        'filename': 'Kylie.mp4',
                        'fps': 23.976023976023978,
                        'height': 720,
                        'size': 37594080,
                        'width': 1280},
           'updated_at': '2023-05-05T00:19:57Z'},
          {'_id': '64544b9286daab572f34949e',
           'created_at': '2023-05-05T00:19:27Z',
           'metadata': {'duration': 34.333333,
                        'engine_id': 'marengo2.5',
                        'filename': 'Oh_my.mp4',
                        'fps': 30,
                        'height': 1280,
                        'size': 10480126,
                        'width': 720},
           'updated_at': '2023-05-05T00:19:30Z'},
          .
					.
					.
					{'_id': '64544b8786daab572f349496',
           'created_at': '2023-05-05T00:19:18Z',
           'metadata': {'duration': 14.333333,
                        'engine_id': 'marengo2.5',
                        'filename': 'cats.mp4',
                        'fps': 30,
                        'height': 1280,
                        'size': 1304438,
                        'width': 720},
           'updated_at': '2023-05-05T00:19:19Z'}],
 'page_info': {'limit_per_page': 20,
               'page': 1,
               'total_duration': 1363.925599,
               'total_page': 1,
               'total_results': 11}}
[{'video_id': '64544bb486daab572f3494a0', 'video_name': 'JetTila.mp4'},
 {'video_id': '64544bad86daab572f34949f', 'video_name': 'Kylie.mp4'},
 {'video_id': '64544b9286daab572f34949e', 'video_name': 'Oh_my.mp4'},
 {'video_id': '64544b8e86daab572f34949c', 'video_name': 'Pitbull.mp4'},
 {'video_id': '64544b9286daab572f34949d', 'video_name': 'She.mp4'},
 {'video_id': '64544b8d86daab572f34949b', 'video_name': 'fun.mp4'},
 {'video_id': '64544b8986daab572f349497', 'video_name': 'Dance.mp4'},
 {'video_id': '64544b8986daab572f349498', 'video_name': 'Jennie.mp4'},
 {'video_id': '64544b8a86daab572f349499', 'video_name': 'McDonald.mp4'},
 {'video_id': '64544b8c86daab572f34949a', 'video_name': 'Orangutan.mp4'},
 {'video_id': '64544b8786daab572f349496', 'video_name': 'cats.mp4'}]
 </code></pre>

비디오 분류

코드 작성으로 넘어가기 위해 핵심 개념을 명료하게 살피고 가겠습니다. 기술적인 코드 구현만을 바란다면 이 단락은 편하게 훑어보고 소스 부분으로 도약해도 좋습니다. Twelve Labs에서 비디오를 정교하게 탐지하기 위해 아래 속성들을 파라미터로 제공해 분류 동작을 긴밀하게 제어할 수 있습니다.

  • classes: 플랫폼이 탐지해내길 바라는 객체나 행위들의 구체적인 명칭과 설명 정의들을 포함한 오브젝트들의 배열입니다. 각 오브젝트는 아래 속성을 가집니다.

  • name: 특정 클래스에 정의해 부여하고 싶은 명칭(문자열 형태)입니다.

  • prompts: 해당 클래스에 내포된 상태들을 구체적으로 묘사하여 제공하는 설명적 표현(문자열 배열 형태)입니다. 플랫폼은 바로 이곳에 제공된 힌트 문구에 온전히 의존하여 비디오 안의 일치 맥락을 포착하고 분류합니다.

  • threshold: 사용자가 지정한 요청 프롬프트들에 대해 플랫폼 모델이 지닌 확신의 수준(Confidence Score)을 기반으로, 어느 정도로 긴밀하고 선별적으로 결과를 추려낼지 제어하는 값입니다. 범위는 최소 0부터 최대 100까지 설정이 가능하고 만약 입력하지 않은 상태에서는 기본 임계치인 75가 자동으로 세팅 적용됩니다. 적합한 범위를 설정해 원하지 않는 부정확한 조각들을 미연에 효과적으로 걷어낼 수 있습니다.

그럼 분류 기준을 정하고 Twelve Labs의 classify API를 호출해 분류 요청을 전송해 보겠습니다. 우선 이번 데모 프로젝트에서는 기본 임계치 설정을 유지하고 관찰하겠습니다:

<pre><code class="python"># Construct the URL of the `/classify/bulk` endpoint
CLASSIFY_BULK_URL = f"{API_URL}/classify/bulk"

# Set the header of the request
headers = {
    "x-api-key": API_KEY
}

# Declare a dictionary named `data`
data =  {
  "conversation_option": "semantic",
  "options": ["visual", "conversation"],
  "index_id" : INDEX_ID,
  "include_clips": False,
  "classes": [
      {
            "name": "BeautyTok",
            "prompts": [
                "Makeup",
                "Skincare",
                "cosmetic products",
                "doing nails",
                "doing hair",
                "DanceTok",
                "dance tutorial",
                "dance competition",
                "dance challenge",
                "dance trend",
                "dancing with friends"
            ]
        },
        {
            "name": "CookTok",
            "prompts": [
                "cooking tutorial",
                "cooking utensils",
                "baking tutorials",
                "recipes",
                "restaurants",
                "food",
                "pasta"
            ]
        },
        {
            "name": "AnimalTok",
            "prompts": [
                "dog",
                "cat",
                "birds",
                "fish",
                "playing with pets",
                "pets doing funny things"
            ]
        },
        {
            "name": "ArtTok",
            "prompts": [
                "handicraft",
                "drawing",
                "graffiti",
                "sketching",
                "digital art",
                "coloring",
                "sketchbook",
                "artwork",
                "artists"
            ]
        }
  ]
}

# Make a classification request
response = requests.post(CLASSIFY_BULK_URL, headers=headers, json=data)
print (f'Status code: {response.status_code}')
pprint(response.json())
duration_data = response.json()
</code></pre>

클래스 내에 설정된 프롬프트들이 매칭되었을 때, 이를 바탕으로 비디오 전체에 최종적인 클래스 라벨이 지정됩니다. 가장 확실한 매치 결과를 끌어내고 정교함을 유지하려면, 연관성이 높고 풍부한 프롬프트를 지정해 주는 것이 중요합니다. 꼭 명심해 두셔야 할 사항은, 비디오 안에서 탐지된 관련 클립들의 총 누적 합산 길이가 비디오 전체 상영 시간 기준 50%를 만족하여 초과하는 수준이어야 해당 클래스 라벨이 공식적으로 지정된다는 점입니다. 이 누적 시간은 프롬프트와 매칭된 개별 비디오 클립들을 결합하여 산출됩니다.

자, 그럼 저희가 수행한 classification API 호출 결과값을 보실까요? 여기서 "duration_ratio"는 비디오 전체 대비 매칭된 클립들의 결합 비율을 계산해 준 값이며, "score"는 예측에 대응하는 플랫폼 모델의 신뢰 수준을 타당한 통계 수치로 표명합니다. "name"은 대조 완료된 클래스 명칭이며, 발견된 매칭 리스트들은 신뢰도가 높은 순서대로 내림차순 정렬되어 출력됩니다.

<pre><code class="python">Status code: 200
{'data': [{'classes': [{'duration_ratio': 1,
                        'name': 'AnimalTok',
                        'score': 95.35111111111111}],
           'video_id': '64544b8786daab572f349496'},
          {'classes': [{'duration_ratio': 1,
                        'name': 'AnimalTok',
                        'score': 95.14666666666668}],
           'video_id': '64544b8e86daab572f34949c'},
          .
			.
			.
          {'classes': [{'duration_ratio': 0.8175611166393244,
                        'name': 'ArtTok',
                        'score': 89.45777777777778}],
           'video_id': '64544b9286daab572f34949d'}],
 'page_info': {'limit_per_page': 10,
               'next_page_token': '',
               'page_expired_at': '2023-05-05T17:37:30Z',
               'prev_page_token': '',
               'total_results': 10}}
 </code></pre>

이번엔 위와 동일한 코드를 호출하되 약간의 변주를 가해 보겠습니다. include_clips 플래그 설정을 True로 지정하는 것이죠. 이렇게 요청하면 각 클래스에 지정한 프롬프트들과 정확히 일치하는 개별적인 실제 매칭 조각(클립)들의 상세 메타데이터까지 온전히 한꺼번에 응답으로 받아내 활용할 수 있게 됩니다.

<pre><code class="python">
# Construct the URL of the `/classify/bulk` endpoint
CLASSIFY_BULK_URL = f"{API_URL}/classify/bulk"

# Set the header of the request
headers = {
    "x-api-key": API_KEY
}

# Declare a dictionary named `data`
data =  {
  "conversation_option": "semantic",
  "options": ["visual", "conversation"],
  "index_id" : INDEX_ID,
  "include_clips": True,
  "classes": [
      {
            "name": "BeautyTok",
            "prompts": [
                "Makeup",
                "Skincare",
                "cosmetic products",
                "doing nails",
                "doing hair",
                "DanceTok",
                "dance tutorial",
                "dance competition",
                "dance challenge",
                "dance trend",
                "dancing with friends"
            ]
        },
        {
            "name": "CookTok",
            "prompts": [
                "cooking tutorial",
                "cooking utensils",
                "baking tutorials",
                "recipes",
                "restaurants",
                "food",
                "pasta"
            ]
        },
        {
            "name": "AnimalTok",
            "prompts": [
                "dog",
                "cat",
                "birds",
                "fish",
                "playing with pets",
                "pets doing funny things"
            ]
        },
        {
            "name": "ArtTok",
            "prompts": [
                "handicraft",
                "drawing",
                "graffiti",
                "sketching",
                "digital art",
                "coloring",
                "sketchbook",
                "artwork",
                "artists"
            ]
        }
  ]
}

# Make a classification request
response = requests.post(CLASSIFY_BULK_URL, headers=headers, json=data)
print (f'Status code: {response.status_code}')
pprint(response.json())
duration_data = response.json()
</code></pre>

출력 결과:

<pre><code class="python">{'data': [{'classes': [{'clips': [{'end': 14,
                                   'option': '',
                                   'prompt': 'cat',
                                   'score': 84.77,
                                   'start': 0},
                                  {'end': 14,
                                   'option': '',
                                   'prompt': 'pets doing funny things',
                                   'score': 83.56,
                                   'start': 8.34375},
                                  {'end': 14,
                                   'option': '',
                                   'prompt': 'playing with pets',
                                   'score': 68.11,
                                   'start': 8.34375},
                                  {'end': 8.34375,
                                   'option': '',
                                   'prompt': 'pets doing funny things',
                                   'score': 58.26,
                                   'start': 0}],
                        'duration_ratio': 1,
                        'name': 'AnimalTok',
                        'score': 95.35111111111111}],
			.
			.
			.	
           'video_id': '64544b8786daab572f349496'},
          {'classes': [{'clips': [{'end': 49.189,
                                   'option': '',
                                   'prompt': 'artists',
                                   'score': 78.14,
                                   'start': 0.55},
                                  {'end': 23.659,
                                   'option': '',
                                   'prompt': 'sketching',
                                   'score': 58.85,
                                   'start': 0.55}],
                        'duration_ratio': 0.8175611166393244,
                        'name': 'ArtTok',
                        'score': 89.45777777777778}],
			.
			.
			.
			'video_id': '64544b9286daab572f34949d'}],
 'page_info': {'limit_per_page': 10,
               'next_page_token': '',
               'page_expired_at': '2023-05-05T17:37:30Z',
               'prev_page_token': '',
               'total_results': 10}}
               </code></pre>

가독성을 위해 출력 일부를 축약했습니다. 이제 출력에 각 비디오의 클립 데이터가 나타나며, 세부적인 시작 및 종료 타임스탬프와 특정 클립 및 연관 프롬프트에 대한 신뢰도 점수가 어떻게 표시되는지 확인해 보세요. 저희는 각 프롬프트에 연결된 해당 분류 옵션(예: visual 및 conversation, 여기서 visual은 시각적 매치, conversation은 대화 매치를 나타냄)을 통합할 수 있도록 API 엔드포인트를 꾸준히 개선해 가고 있습니다.

이제 받아온 JSON 분류 데이터와 앞서 생성한 비디오 ID-이름 데이터 매핑 정보를 로컬 상에서 지속적으로 재활용할 수 있도록 직렬화(pickle)하여 로컬에 저장해 두겠습니다.

<pre><code class="python">import pickle

with open('video_id_name_list.pickle', 'wb') as handle:
    pickle.dump(video_id_name_list, handle, protocol=pickle.HIGHEST_PROTOCOL)

with open('duration_data.pickle', 'wb') as handle:
    pickle.dump(duration_data, handle, protocol=pickle.HIGHEST_PROTOCOL)

with open('clips_data.pickle', 'wb') as handle:
    pickle.dump(clips_data, handle, protocol=pickle.HIGHEST_PROTOCOL)
    </code></pre>

데모 앱 제작

이전 튜토리얼에서 진행했던 방식과 마찬가지로, 저장(직렬화)해 둔 인덱싱 정보들을 웹 페이지로 호스팅하여 매끄럽게 연동해 줄 Flask 기반의 유쾌한 데모 앱을 빌드해보겠습니다. 이 정돈된 데이터들을 바탕으로 로컬 드라이브의 실제 동영상들을 결합하여, 시각적으로 매력적이고 직관적인 웹 기반 분석 대시보드를 그리게 됩니다. 이를 통해 Twelve Labs의 동영상 분류 시스템이 애플리케이션에 가치를 더하고 완성도를 높여주는 과정을 직접 경험하실 수 있을 것입니다.

전체적인 디렉토리 구조는 다음과 같이 명확히 정의됩니다:

<pre><code class="markdown">my_flask_app/
│   app.py
│   sample_notebook.ipynb
└───templates/
│	│   index.html
└───classify/
	│   <your_video_1.mp4><your_video_2.mp4><your_video_3.mp4>
			.
			.
			.
</code></pre>

Flask 앱 코드

이번 가이드에서는 로컬의 특정 폴더에서 비디오 파일을 안전하게 가져와 재생하면서, HTML5 비디오 플레이어를 통해 타임스탬프 기반 의도된 시작지점 조각(클립)을 똑똑하게 지정 구동하는 조절 메커니즘을 살짝 녹였습니다. Flask 애플리케이션 내에 자리한 serve_video 함수가 실행 스크립트와 동일한 소스선상의 classify 디렉토리로부터 비디오 스트림을 웹 인터페이스에 올바르게 송출하고, HTML5 템플릿 상에 선언된 url_for('serve_video', filename=video_mapping[video.video_id]) 속성을 거치면서 브라우저 주소와 기민하게 결합하게 됩니다.

또한, "include_clips" 옵션 하에서 받아온 메타데이터에는 매우 유기적인 디테일이 살아있습니다. 다소 과도하고 분산되어 있을 수 있는 정보들을 한결 깔끔하고 응축된 뷰로 간소화하여 데모의 몰입도를 높이기 위해, 저는 get_top_clips 라는 보조 함수를 정의했습니다. 이 도구는 탐지된 키워드 맥락 중에서 가장 우수한 상위 3개의 고유 프롬프트 클립들만을 명확히 선별해내어, 한층 정돈되고 수준 높은 대시보드 구조를 제공합니다.

아래는 온전한 연동 메커니즘이 집약된 "app.py" 파일의 전체 코드 구성입니다:

<pre><code class="python">from flask import Flask, render_template, send_from_directory
import pickle
import os
from collections import defaultdict

app = Flask(__name__)

# Replace the following dictionaries with your data
with open('video_id_name_list.pickle', 'rb') as handle:
    video_id_name_list = pickle.load(handle)

with open('duration_data.pickle', 'rb') as handle:
    duration_data = pickle.load(handle)

with open('clips_data.pickle', 'rb') as handle:
    clips_data = pickle.load(handle)

VIDEO_DIRECTORY = os.path.join(os.path.dirname(os.path.realpath(__file__)), "classify")

@app.route('/<path:filename>')
def serve_video(filename):
    print(VIDEO_DIRECTORY, filename)
    return send_from_directory(directory=VIDEO_DIRECTORY, path=filename)

def get_top_clips(clips_data, num_clips=3):
    top_clips = defaultdict(list)
    for video in clips_data['data']:
        video_id = video['video_id']
        unique_prompts = set()
        for clip_class in video['classes']:
            for clip in clip_class['clips']:
                if clip['prompt'] not in unique_prompts and len(unique_prompts) < num_clips:
                    top_clips[video_id].append(clip)
                    unique_prompts.add(clip['prompt'])
    return top_clips

@app.route('/')
def home():
    video_id_name_dict = {video['video_id']: video['video_name'] for video in video_id_name_list}
    top_clips = get_top_clips(clips_data)
    return render_template('index.html', video_mapping=video_id_name_dict, duration_data=duration_data, top_clips=top_clips)


if __name__ == '__main__':
    app.run(debug=True)
</code></pre>

HTML 템플릿

아래는 Jinja2 템플릿 문법을 사용하여, 앞서 직렬화해 전달한 분석 데이터 세트를 화면상에 기민하게 반복 순회하며 표현하도록 짜인 HTML 명세서입니다. 이 마크업 단락은 로컬에 보관해 둔 비디오의 상영 구획을 끌어내고, 설계한 기준에 매칭된 분류 전경을 시각적 게이지바와 함께 미려하게 연출해 줍니다.

<pre><code class="language-html"><!doctype html>
<html lang="en">
<head>
    <link rel="shortcut icon" href="#" />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Classification Output</title>
    <style>
        body {
            background-color: #f0e7cc;
            font-family: Arial, sans-serif;
        }
        .container {
            width: 80%;
            margin: 0 auto;
            padding-bottom: 30px;
        }
        video {
            display: block;
            margin: 0 auto;
        }
        .progress-bar {
            width: 20%;
            height: 20px;
            background-color: #f0f0f0;
            margin-bottom: 10px;
            display: inline-block;
            vertical-align: middle;
            margin-left: 10px;
        }
        .progress {
            height: 100%;
            background-color: #4caf50;
        }
        .score {
            display: inline-block;
            vertical-align: middle;
            font-weight: bold;
        }
        .video-category {
            padding-bottom: 30px;
            margin-bottom: 20px;
        }
        button {
            background-color: #008CBA;
            border: none;
            color: white;
            padding: 8px 16px;
            text-align: center;
            text-decoration: none;
            display: inline-block;
            font-size: 14px;
            margin: 4px 2px;
            cursor: pointer;
            border-radius: 4px;
            box-shadow: 0 1px 2px rgba(0,0,0,0.2);
            transition: background-color 0.2s ease;
        }
        button:hover {
            background-color: #006494;
        }
        h1 {
            color: #070707;
            text-align: center;
            background-color: #cb6a16; /* Change the background color to your desired color */
            padding: 10px;
            margin-bottom: 20px;  
        }
        .clips-container {
            text-align: center;
        }

    </style>
    <script>
        function playClip(videoPlayer, start, end) {
            videoPlayer.currentTime = start;
            videoPlayer.play();
            videoPlayer.ontimeupdate = function () {
                if (videoPlayer.currentTime >= end) {
                    videoPlayer.pause();
                }
            };
        }
    </script>
</head>
<body>
    <div class="container">
        <h1>Video Classification</h1>
        {% for video in duration_data.data %}
            {% set video_id = video.video_id %}
            {% set video_path = video_mapping[video_id] %}
            <div class="video-category">
                <h2>{{ video.classes[0].name }}</h2>
                <p>Video file: {{video_path}}</p>
                <div>
                    <span class="score">Score: {{ '%.2f'|format(video.classes[0].score) }}</span>
                    <div class="progress-bar">
                        <div class="progress" style="width: {{ video.classes[0].score }}%;"></div>
                    </div>
                </div>
                <div>
                    <span class="score">Duration: {{ '%.2f%%'|format(video.classes[0].duration_ratio * 100) }}</span>
                    <div class="progress-bar">
                        <div class="progress" style="width: {{ video.classes[0].duration_ratio * 100 }}%;"></div>
                    </div>
                </div>
                <video id="video-{{ video_id }}" width="480" height="320" controls>
                    <source src="{{ url_for('serve_video', filename=video_path) }}" type="video/mp4">
                    Your browser does not support the video tag.
                </video>
                <div class="clips-container">
                    <h3>Sample Clips</h3>
                    {% for clip in top_clips[video_id] %}
                    <button onclick="playClip(document.getElementById('video-{{ video_id }}'), {{ clip.start }}, {{ clip.end }})">Clip: {{ clip.prompt }} | Start {{ '%.2f'|format(clip.start) }}s - End {{ '%.2f'|format(clip.end) }}s - Score {{ '%.2f'|format(clip.score) }}</button>
                    {% endfor %}
            </div>
            <br /><br />
        {% endfor %}
    </div>
</body>
</html>
</code></pre>

Flask 앱 구동하기

훌륭합니다! 이제 Jupyter Notebook의 마지막 셀을 실행해 방금 설정한 Flask 앱을 정상 론칭시켜 보시죠:

<pre><code class="python">%run app.py
</code></pre>

모든 흐름이 예상대로 문제없이 완벽하게 귀결되는 것을 확인할 수 있을 것입니다 😊:

로컬 주소 링크 http://127.0.0.1:5000에 직접 진입해 주시면 다음과 같은 미려한 최종 연출 화면을 영접하게 됩니다:

본 완독 가이드에서 쓰인 모든 파이썬 스크립트 소스와 Jupyter Notebook의 전체 원형을 한눈에 살펴보실 수 있는 통합 보관 주소는 다음과 같습니다 - https://tinyurl.com/classifyNotebook

새로운 가능성을 실험해볼 준비가 되셨나요?

다음과 같은 흥미진진한 구상을 여러분의 비즈니스 도구에 도입해 보세요:

  1. 단순히 상위 3개의 힌트 샘플 단락만을 거르는 데 그치지 않고, 페이지네이션(Pagination) 및 지연 로딩(Lazy Loading) 기법을 멋지게 탑재하여 동영상에서 포착된 수많은 의미 조각 클립들을 빈틈없이 정방향 화면에 연동해 보세요. 이를 통해 사용자는 훨씬 넓은 반경의 카테고리화 대조군을 가시적으로 면밀히 심사하고 분석 전경을 전방위적으로 조망해낼 수 있습니다.

  2. 키워드 및 프롬프트의 의미 구성 방식(Short/Medium/Long-Tail)을 다변화해 보거나 임계치 설정을 유연하게 조절해가며 발견되는 현상 정보를 다채롭게 수집해 보세요. 얻어낸 귀중한 지식 통찰을 저희 Discord 미팅 채널을 통해 전 세계 멀티모달 기술 매니아 동료들과 함께 나누는 멋진 지적 축제를 누려보시길 권합니다.

마치며

앞으로 공개될 흥미진진한 소식들을 기대해 주시기 바랍니다! 아직 탐조등을 켜고 합류하지 않으셨다면, 멀티모달 인공지능의 개척 트렌드를 함께 호흡하고 유익을 도모하는 활기찬 Discord 커뮤니티의 문을 두드려 오손도손 이야기꽃을 피워보시길 진심으로 고대합니다.

비디오 분류란 비디오의 콘텐츠를 기반으로 하나 이상의 미리 정의된 카테고리 또는 라벨을 자동으로 지정하는 프로세스를 의미합니다. 이 작업은 비디오에 나타나는 이벤트, 행동, 사물 또는 기타 특징을 인식하고 이해하기 위해 비디오의 시각적 정보 및 오디오 정보를 분석하는 과정을 포함합니다. 비디오 분류는 컴퓨터 비전 분야의 중요한 연구 영역이며, 비디오 인덱싱, 콘텐츠 기반 비디오 검색, 비디오 추천, 비디오 감시, 인간 행동 인식 등 매우 다양한 실무 분야에 적용되고 있습니다.

과거에는 비디오 분류가 미리 정의된 카테고리나 라벨에 국한되어 이벤트, 행동, 사물 등의 특징을 식별하는 데 초점을 맞추었습니다. 모델을 재학습하고 기준을 업데이트하지 않은 채 분류 기준을 맞춤 설정하는 것은 아주 먼 꿈처럼 보였습니다. 하지만 바로 이 시점에 Twelve Labs의 classification API가 등장하여, 학습 절차에 구애받지 않고 우리의 맞춤 기준에 따라 비디오를 근실시간으로 아주 쉽고 강력하게 분류해 줍니다. 그야말로 판도를 바꾸는 게임 체인저라 할 수 있죠!

Twelve Labs Classification API - 개념 개요

Twelve Labs의 classification API는 각 비디오 내에서 클래스 라벨이 차지하는 시간(총 길이 대비 비율)을 기준으로 인덱싱된 비디오에 라벨을 지정하도록 설계되었습니다. 만약 해당 비율이 50% 미만인 경우, 클래스 라벨은 적용되지 않습니다. 따라서 특히 대용량 비디오를 업로드할 때는 클래스와 프롬프트를 신중하게 설계하는 것이 중요합니다. 이 API 서비스는 개수 제한 없이 원하는 만큼 많은 클래스를 지원할 수 있으며, 하나의 클래스 내에 원하는 만큼의 프롬프트를 추가할 수 있습니다.

예를 들어, 여러분의 반려견인 브루노(Bruno)와 반려묘인 칼라(Karla)가 다양한 장난을 치는 재미있는 비디오 모음집을 가지고 있다고 가정해 봅시다. 이 비디오들을 Twelve Labs 플랫폼에 업로드하고, 사랑스러운 털뭉치 친구들의 유쾌한 탈출극에 딱 맞춘 커스텀 분류 기준을 이렇게 생성할 수 있습니다:

<pre><code class="json">"classes": [
      {
            "name": "Doge_Bruno",
            "prompts": [
                "playing with my dog",
                "my dog doing funny things",
                "dog playing with water"
            ]
        },
        {
            "name": "Kitty_Karla",
            "prompts": [
                "cat jumping",
                "cat playing with toys"
            ]
        }
  ]
  </code></pre>

단 한 번의 API 호출만으로 직접 구축한 분류 기준에 따라 업로드된 비디오들을 쉽게 분류할 수 있습니다. 혹시 몇 가지 프롬프트를 잊었거나 새로운 클래스를 도입하고 싶다면, 해당 JSON에 클래스와 프롬프트를 더 추가하기만 하면 됩니다. 새로운 모델을 학습시키거나 기존 모델을 재학습시킬 필요가 전혀 없어 모든 프로세스가 매우 간편합니다.

분류 결과

빠른 요약

사전 준비 사항: 본 튜토리얼을 매끄럽게 따라오려면, Twelve Labs API 제품군에 가입하고 필요한 패키지들을 설치해 주세요. 기본 개념을 손쉽게 파악하기 위해 첫 번째두 번째 튜토리얼을 먼저 읽어보시는 것을 권장합니다 🤓.

비디오 업로드: 비디오를 Twelve Labs 플랫폼으로 전송하면 간편하게 인덱싱이 완료됩니다. 이를 통해 즉석에서 커스텀 분류 기준을 더하고 콘텐츠를 자유롭게 관리할 수 있습니다! 게다가 머신러닝 모델을 직접 학습시킬 필요조차 없습니다 😆😁😊.

비디오 분류: 이제 본격적으로 즐겨볼 시간입니다! 우리만의 맞춤 클래스들과 각 클래스에 들어갈 매력적인 프롬프트들을 디자인해 보겠습니다. 기준을 모두 정했다면, 지체 없이 바로 가동해 결과를 받아볼 수 있습니다. 막힘없이 곧바로 핵심으로 가 보시죠! 🍿✌️🥳

데모 앱 제작: classification API를 통해 얻은 결과를 활용하고, 컴퓨터의 로컬 폴더에 저장된 비디오에 액세스하는 Flask 기반 앱을 만들어 볼 것입니다. 그런 다음, 세련되게 디자인된 HTML 페이지를 렌더링해 분류 결과를 아주 멋지게 보여주겠습니다 🔍💻🎨.👨‍🎨

사전 준비 사항

첫 번째 튜토리얼에서는 간단한 자연어 쿼리를 사용해 비디오 내에서 특정 순간을 찾는 기본 방법을 다루었습니다. 과정을 복잡하지 않게 진행하기 위해 단 하나의 비디오만 플랫폼에 업로드했으며, 인덱스 생성 및 구성, 태스크 API 정의, 비디오 인덱싱 작업의 기본 모니터링, 그리고 Flask 기반 데모 앱을 만드는 단계별 설명을 제공했습니다.

두 번째 튜토리얼에서는 한 걸음 더 나아가, 여러 검색 쿼리를 조합하여 더욱 정밀하고 타겟팅된 검색을 구현하는 방법을 탐구했습니다. 여러 비디오를 비동기식으로 업로드하고, 여러 개의 인덱스를 생성했으며, 비디오 인덱싱 작업을 모니터링하고 작업 완료 예정 시간 등을 조회할 수 있는 추가 코드를 구현했습니다. 또한 Flask 앱이 여러 비디오를 수용하고 HTML 템플릿을 사용해 보여줄 수 있도록 구성했습니다.

이러한 흐름을 이어가며, 이번 튜토리얼에서는 파이썬의 기본 내장 라이브러리인 concurrent.futures를 사용하여 비디오를 동기식(동시성 구조)으로 업로드해 볼 것입니다. 비디오의 인덱싱 상태를 모니터링하여 CSV 파일에 기록하고, 입력된 분류 기준과 핵심 분류 API 응답 필드들을 HTML 템플릿에 보기 좋게 표현해 결과를 훨씬 직관적으로 이해할 수 있도록 만들겠습니다.

이 글이나 이전 튜토리얼들을 읽는 도중 해결하기 어려운 부분이 생긴다면 언제든 주저하지 말고 문의해 주세요! 저희는 KTX보다 빠른 속도로 공식 Discord 서버를 통해 신속한 지원을 제공하고 있습니다 🚅🏎️⚡️. 이메일 편이 더 편하시다면 이메일로 연락해 주셔도 좋습니다. Twelve Labs는 현재 오픈 베타 단계에 있으므로 편리하게 Twelve Labs 계정을 생성하고 API 대시보드에 접근하여 API 키를 발급받을 수 있습니다. 무상 제공되는 크레딧을 통해 최대 10시간에 달하는 비디오 콘텐츠를 분류해 보실 수 있습니다.

<pre><code class="python">%env API_KEY=<your_API_key>
%env API_URL=https://api.twelvelabs.io/v1.1

!pip install requests
!pip install flask

import os
import requests
import glob
from pprint import pprint


#Retrieve the URL of the API and the API key
API_URL = os.getenv("API_URL")
assert API_URL

API_KEY = os.getenv("API_KEY")
assert API_KEY
</code></pre>

비디오 업로드

인덱스를 생성하고 비디오 업로드를 위해 구성하기:

<pre><code class="python"># Construct the URL of the `/indexes` endpoint
INDEXES_URL = f"{API_URL}/indexes"

# Set the header of the request
default_header = {
    "x-api-key": API_KEY
}

# Define a function to create an index with a given name
def create_index(index_name, index_options, engine):
    # Declare a dictionary named data
    data = {
        "engine_id": engine,
        "index_options": index_options,
        "index_name": index_name,
    }

    # Create an index
    response = requests.post(INDEXES_URL, headers=default_header, json=data)

    # Store the unique identifier of your index
    INDEX_ID = response.json().get('_id')

    # Check if the status code is 201 and print success
    if response.status_code == 201:
        print(f"Status code: {response.status_code} - The request was successful and a new index was created.")
    else:
        print(f"Status code: {response.status_code}")
    pprint(response.json())
    return INDEX_ID


# Create the indexes
index_id_content_classification = create_index(index_name = "insta+tiktok", index_options=["visual", "conversation", "text_in_video", "logo"], engine = "marengo2.5")

# Print the created index IDs
print(f"Created index IDs: {index_id_content_classification}")
</code></pre>
<pre><code class="bash">Status code: 201 - The request was successful and a new index was created.
{'_id': '64544b858b1dd6cde172af77'}
Created index IDs: 64544b858b1dd6cde172af77
</code></pre>

업로드 함수 작성

이번엔 지정한 폴더 내의 모든 비디오 파일을 자동으로 수집하고, 파일명과 동일한 이름으로 플랫폼에 업로드하는 효율적인 코드를 준비했습니다. 파이썬 라이브러리를 통해 동시성 처리를 적극 활용해 동기식처럼 자연스럽게 처리됩니다. 인덱싱을 원하는 비디오들을 단일 폴더에 일괄적으로 넣기만 하면 준비는 끝납니다! 전체 인덱싱 프로세스 속도는 포함된 비디오 중 가장 긴 비디오 길이를 기준으로 그 길이의 대략 40% 정도면 완료됩니다. 나중에 해당 인덱스에 비디오를 계속해서 더 추가하고 싶으신가요? 매우 간단합니다! 사전에 번거롭게 폴더를 새로 구조화할 필요 없이 기존 폴더에 담아주면 됩니다. 이 영리한 로직이 사전에 업로드가 마쳤거나 진행 중인 동일한 이름의 파일 여부를 확인하므로, 중복 처리를 확실하게 피해 줍니다. 엄청나게 명쾌하고 실용적이죠? 😄

<pre><code class="python">import os
import requests
from concurrent.futures import ThreadPoolExecutor

TASKS_URL = f"{API_URL}/tasks"
TASK_ID_LIST = []
video_folder = 'classify'  # folder containing the video files
INDEX_ID = '64544b858b1dd6cde172af77'

def upload_video(file_name):
    # Validate if a video already exists in the index
    task_list_response = requests.get(
        TASKS_URL,
        headers=default_header,
        params={"index_id": INDEX_ID, "filename": file_name},
    )
    if "data" in task_list_response.json():
        task_list = task_list_response.json()["data"]
        if len(task_list) > 0:
            if task_list[0]['status'] == 'ready': 
                print(f"Video '{file_name}' already exists in index {INDEX_ID}")
            else:
                print("task pending or validating")
            return

    # Proceed further to create a new task to index the current video if the video didn't exist in the index already
    print("Entering task creation code for the file: ", file_name)
    
    if file_name.endswith('.mp4'):  # Make sure the file is an MP4 video
        file_path = os.path.join(video_folder, file_name)  # Get the full path of the video file
        with open(file_path, "rb") as file_stream:
            data = {
                "index_id": INDEX_ID,
                "language": "en"
            }
            file_param = [
                ("video_file", (file_name, file_stream, "application/octet-stream")),] #The video will be indexed on the platform using the same name as the video file itself.
            response = requests.post(TASKS_URL, headers=default_header, data=data, files=file_param)
            TASK_ID = response.json().get("_id")
            TASK_ID_LIST.append(TASK_ID)
            # Check if the status code is 201 and print success
            if response.status_code == 201:
                print(f"Status code: {response.status_code} - The request was successful and a new resource was created.")
            else:
                print(f"Status code: {response.status_code}")
            print(f"File name: {file_name}")
            pprint(response.json())
            print("\n")

# Get list of video files
video_files = [f for f in os.listdir(video_folder) if f.endswith('.mp4')]

# Create a ThreadPoolExecutor
with ThreadPoolExecutor() as executor:
    # Use executor to run upload_video in parallel for all video files
    executor.map(upload_video, video_files)
</code></pre>

인덱싱 프로세스 모니터링

업로드 함수처럼, 동시다발적으로 전개되는 모든 백그라운드 태스크의 상태를 놓치지 않고 꼼꼼히 확인하기 위해 모니터링 로직을 유기적으로 설계했습니다. 동시에 인덱싱 중인 각 비디오 파일의 예상 남은 소요 시간 및 구체적인 업로드 퍼센티지를 정돈된 형태의 CSV 파일에 차곡차곡 기록해 줍니다. 이 편리한 함수는 로컬 디렉터리의 모든 파일이 예외 없이 완벽하게 인덱싱을 끝마칠 때까지 부지런히 동작을 지속합니다. 마지막 단계를 마치면, 정밀하게 계산된 총 경과 시간을 초 단위를 기준으로 알기 쉽게 전광판처럼 보여줍니다. 놀랍도록 강력하고 편리한 모니터링 장치입니다.

<pre><code class="python">import time
import csv
from concurrent.futures import ThreadPoolExecutor, as_completed

def monitor_upload_status(task_id):
    TASK_STATUS_URL = f"{API_URL}/tasks/{task_id}"
    while True:
        response = requests.get(TASK_STATUS_URL, headers=default_header)
        STATUS = response.json().get("status")
        if STATUS == "ready":
            return task_id, STATUS
        remain_seconds = response.json().get('process', {}).get('remain_seconds', None)
        upload_percentage = response.json().get('process', {}).get('upload_percentage', None)
        if remain_seconds is not None:
            print(f"Task ID: {task_id}, Remaining seconds: {remain_seconds}, Upload Percentage: {upload_percentage}")
        else:
             print(f"Task ID: {task_id}, Status: {STATUS}")
        time.sleep(10)

# Define starting time
start = time.time()
print("Starting to monitor...")

# Monitor the indexing process for all tasks
with ThreadPoolExecutor() as executor:
    futures = {executor.submit(monitor_upload_status, task_id) for task_id in TASK_ID_LIST}
    with open('upload_status.csv', 'w', newline='') as file:
        writer = csv.writer(file)
        writer.writerow(["Task ID", "Status"])
        for future in as_completed(futures):
            task_id, status = future.result()
            writer.writerow([task_id, status])

# Define ending time
end = time.time()
print("Monitoring finished")
print("Time elapsed (in seconds): ", end - start)
</code></pre>

출력 결과:

<pre><code class="language-plaintext">Starting to monitor...
Monitoring finished
Time elapsed (in seconds):  253.00311
</code></pre>

인덱스 내 모든 비디오 목록 조회

필요한 비디오들이 완벽히 인덱싱되었는지 꼼꼼하게 더블체크하기 위해 인덱스 내의 모든 비디오 목록을 조회해 보겠습니다. 아울러 각 비디오 ID와 고유한 파일 매칭 관계를 손쉽게 추적할 수 있도록 간편한 보조 매핑 리스트를 하나 생성하겠습니다. 이 리스트는 나중에 classification API가 돌려주는 분석 매치 클립(지정한 기준에 완전히 동기화되는 조각 구간들)을 바탕으로 웹상에서 알맞은 실제 파일명을 유연하게 대조해 가져와야 할 때 매우 스마트하게 쓰입니다.

참고로, 업로드된 비디오 총수가 11개이므로 페이지 한계 제한 설정을 20으로 살짝 확장했습니다. 설정되지 않은 API 인터페이스 구조 하에서는 기본값으로 한 페이지당 최대 10개 결과만 나타나기 때문에, 페이지 크기를 키워두지 않으면 11번째 데이터가 유실되거나 video_id_name_list 상에 정상적으로 합쳐지지 못할 여지가 있습니다. 확실한 취합을 위해 한 화면에 전부 들어오도록 여유를 둡시다!

<pre><code class="python"># List all the videos in an index
INDEX_ID='64544b858b1dd6cde172af77'
default_header = {
    "x-api-key": API_KEY
}
# INDEX_ID='64502d238b1dd6cde172a9c5' #movies
# INDEX_ID= '64399bc25b65d57eaecafb35' #lex
INDEXES_VIDEOS_URL = f"{API_URL}/indexes/{INDEX_ID}/videos?page_limit=20"
response = requests.get(INDEXES_VIDEOS_URL, headers=default_header)

response_json = response.json()
pprint(response_json)

video_id_name_list = [{'video_id': video['_id'], 'video_name': video['metadata']['filename']} for video in response_json['data']]

pprint(video_id_name_list)
</code></pre>

출력 결과:

<pre><code class="python">{'data': [{'_id': '64544bb486daab572f3494a0',
           'created_at': '2023-05-05T00:19:33Z',
           'metadata': {'duration': 507.5,
                        'engine_id': 'marengo2.5',
                        'filename': 'JetTila.mp4',
                        'fps': 30,
                        'height': 720,
                        'size': 44891944,
                        'width': 1280},
           'updated_at': '2023-05-05T00:20:04Z'},
          {'_id': '64544bad86daab572f34949f',
           'created_at': '2023-05-05T00:19:32Z',
           'metadata': {'duration': 516.682833,
                        'engine_id': 'marengo2.5',
                        'filename': 'Kylie.mp4',
                        'fps': 23.976023976023978,
                        'height': 720,
                        'size': 37594080,
                        'width': 1280},
           'updated_at': '2023-05-05T00:19:57Z'},
          {'_id': '64544b9286daab572f34949e',
           'created_at': '2023-05-05T00:19:27Z',
           'metadata': {'duration': 34.333333,
                        'engine_id': 'marengo2.5',
                        'filename': 'Oh_my.mp4',
                        'fps': 30,
                        'height': 1280,
                        'size': 10480126,
                        'width': 720},
           'updated_at': '2023-05-05T00:19:30Z'},
          .
					.
					.
					{'_id': '64544b8786daab572f349496',
           'created_at': '2023-05-05T00:19:18Z',
           'metadata': {'duration': 14.333333,
                        'engine_id': 'marengo2.5',
                        'filename': 'cats.mp4',
                        'fps': 30,
                        'height': 1280,
                        'size': 1304438,
                        'width': 720},
           'updated_at': '2023-05-05T00:19:19Z'}],
 'page_info': {'limit_per_page': 20,
               'page': 1,
               'total_duration': 1363.925599,
               'total_page': 1,
               'total_results': 11}}
[{'video_id': '64544bb486daab572f3494a0', 'video_name': 'JetTila.mp4'},
 {'video_id': '64544bad86daab572f34949f', 'video_name': 'Kylie.mp4'},
 {'video_id': '64544b9286daab572f34949e', 'video_name': 'Oh_my.mp4'},
 {'video_id': '64544b8e86daab572f34949c', 'video_name': 'Pitbull.mp4'},
 {'video_id': '64544b9286daab572f34949d', 'video_name': 'She.mp4'},
 {'video_id': '64544b8d86daab572f34949b', 'video_name': 'fun.mp4'},
 {'video_id': '64544b8986daab572f349497', 'video_name': 'Dance.mp4'},
 {'video_id': '64544b8986daab572f349498', 'video_name': 'Jennie.mp4'},
 {'video_id': '64544b8a86daab572f349499', 'video_name': 'McDonald.mp4'},
 {'video_id': '64544b8c86daab572f34949a', 'video_name': 'Orangutan.mp4'},
 {'video_id': '64544b8786daab572f349496', 'video_name': 'cats.mp4'}]
 </code></pre>

비디오 분류

코드 작성으로 넘어가기 위해 핵심 개념을 명료하게 살피고 가겠습니다. 기술적인 코드 구현만을 바란다면 이 단락은 편하게 훑어보고 소스 부분으로 도약해도 좋습니다. Twelve Labs에서 비디오를 정교하게 탐지하기 위해 아래 속성들을 파라미터로 제공해 분류 동작을 긴밀하게 제어할 수 있습니다.

  • classes: 플랫폼이 탐지해내길 바라는 객체나 행위들의 구체적인 명칭과 설명 정의들을 포함한 오브젝트들의 배열입니다. 각 오브젝트는 아래 속성을 가집니다.

  • name: 특정 클래스에 정의해 부여하고 싶은 명칭(문자열 형태)입니다.

  • prompts: 해당 클래스에 내포된 상태들을 구체적으로 묘사하여 제공하는 설명적 표현(문자열 배열 형태)입니다. 플랫폼은 바로 이곳에 제공된 힌트 문구에 온전히 의존하여 비디오 안의 일치 맥락을 포착하고 분류합니다.

  • threshold: 사용자가 지정한 요청 프롬프트들에 대해 플랫폼 모델이 지닌 확신의 수준(Confidence Score)을 기반으로, 어느 정도로 긴밀하고 선별적으로 결과를 추려낼지 제어하는 값입니다. 범위는 최소 0부터 최대 100까지 설정이 가능하고 만약 입력하지 않은 상태에서는 기본 임계치인 75가 자동으로 세팅 적용됩니다. 적합한 범위를 설정해 원하지 않는 부정확한 조각들을 미연에 효과적으로 걷어낼 수 있습니다.

그럼 분류 기준을 정하고 Twelve Labs의 classify API를 호출해 분류 요청을 전송해 보겠습니다. 우선 이번 데모 프로젝트에서는 기본 임계치 설정을 유지하고 관찰하겠습니다:

<pre><code class="python"># Construct the URL of the `/classify/bulk` endpoint
CLASSIFY_BULK_URL = f"{API_URL}/classify/bulk"

# Set the header of the request
headers = {
    "x-api-key": API_KEY
}

# Declare a dictionary named `data`
data =  {
  "conversation_option": "semantic",
  "options": ["visual", "conversation"],
  "index_id" : INDEX_ID,
  "include_clips": False,
  "classes": [
      {
            "name": "BeautyTok",
            "prompts": [
                "Makeup",
                "Skincare",
                "cosmetic products",
                "doing nails",
                "doing hair",
                "DanceTok",
                "dance tutorial",
                "dance competition",
                "dance challenge",
                "dance trend",
                "dancing with friends"
            ]
        },
        {
            "name": "CookTok",
            "prompts": [
                "cooking tutorial",
                "cooking utensils",
                "baking tutorials",
                "recipes",
                "restaurants",
                "food",
                "pasta"
            ]
        },
        {
            "name": "AnimalTok",
            "prompts": [
                "dog",
                "cat",
                "birds",
                "fish",
                "playing with pets",
                "pets doing funny things"
            ]
        },
        {
            "name": "ArtTok",
            "prompts": [
                "handicraft",
                "drawing",
                "graffiti",
                "sketching",
                "digital art",
                "coloring",
                "sketchbook",
                "artwork",
                "artists"
            ]
        }
  ]
}

# Make a classification request
response = requests.post(CLASSIFY_BULK_URL, headers=headers, json=data)
print (f'Status code: {response.status_code}')
pprint(response.json())
duration_data = response.json()
</code></pre>

클래스 내에 설정된 프롬프트들이 매칭되었을 때, 이를 바탕으로 비디오 전체에 최종적인 클래스 라벨이 지정됩니다. 가장 확실한 매치 결과를 끌어내고 정교함을 유지하려면, 연관성이 높고 풍부한 프롬프트를 지정해 주는 것이 중요합니다. 꼭 명심해 두셔야 할 사항은, 비디오 안에서 탐지된 관련 클립들의 총 누적 합산 길이가 비디오 전체 상영 시간 기준 50%를 만족하여 초과하는 수준이어야 해당 클래스 라벨이 공식적으로 지정된다는 점입니다. 이 누적 시간은 프롬프트와 매칭된 개별 비디오 클립들을 결합하여 산출됩니다.

자, 그럼 저희가 수행한 classification API 호출 결과값을 보실까요? 여기서 "duration_ratio"는 비디오 전체 대비 매칭된 클립들의 결합 비율을 계산해 준 값이며, "score"는 예측에 대응하는 플랫폼 모델의 신뢰 수준을 타당한 통계 수치로 표명합니다. "name"은 대조 완료된 클래스 명칭이며, 발견된 매칭 리스트들은 신뢰도가 높은 순서대로 내림차순 정렬되어 출력됩니다.

<pre><code class="python">Status code: 200
{'data': [{'classes': [{'duration_ratio': 1,
                        'name': 'AnimalTok',
                        'score': 95.35111111111111}],
           'video_id': '64544b8786daab572f349496'},
          {'classes': [{'duration_ratio': 1,
                        'name': 'AnimalTok',
                        'score': 95.14666666666668}],
           'video_id': '64544b8e86daab572f34949c'},
          .
			.
			.
          {'classes': [{'duration_ratio': 0.8175611166393244,
                        'name': 'ArtTok',
                        'score': 89.45777777777778}],
           'video_id': '64544b9286daab572f34949d'}],
 'page_info': {'limit_per_page': 10,
               'next_page_token': '',
               'page_expired_at': '2023-05-05T17:37:30Z',
               'prev_page_token': '',
               'total_results': 10}}
 </code></pre>

이번엔 위와 동일한 코드를 호출하되 약간의 변주를 가해 보겠습니다. include_clips 플래그 설정을 True로 지정하는 것이죠. 이렇게 요청하면 각 클래스에 지정한 프롬프트들과 정확히 일치하는 개별적인 실제 매칭 조각(클립)들의 상세 메타데이터까지 온전히 한꺼번에 응답으로 받아내 활용할 수 있게 됩니다.

<pre><code class="python">
# Construct the URL of the `/classify/bulk` endpoint
CLASSIFY_BULK_URL = f"{API_URL}/classify/bulk"

# Set the header of the request
headers = {
    "x-api-key": API_KEY
}

# Declare a dictionary named `data`
data =  {
  "conversation_option": "semantic",
  "options": ["visual", "conversation"],
  "index_id" : INDEX_ID,
  "include_clips": True,
  "classes": [
      {
            "name": "BeautyTok",
            "prompts": [
                "Makeup",
                "Skincare",
                "cosmetic products",
                "doing nails",
                "doing hair",
                "DanceTok",
                "dance tutorial",
                "dance competition",
                "dance challenge",
                "dance trend",
                "dancing with friends"
            ]
        },
        {
            "name": "CookTok",
            "prompts": [
                "cooking tutorial",
                "cooking utensils",
                "baking tutorials",
                "recipes",
                "restaurants",
                "food",
                "pasta"
            ]
        },
        {
            "name": "AnimalTok",
            "prompts": [
                "dog",
                "cat",
                "birds",
                "fish",
                "playing with pets",
                "pets doing funny things"
            ]
        },
        {
            "name": "ArtTok",
            "prompts": [
                "handicraft",
                "drawing",
                "graffiti",
                "sketching",
                "digital art",
                "coloring",
                "sketchbook",
                "artwork",
                "artists"
            ]
        }
  ]
}

# Make a classification request
response = requests.post(CLASSIFY_BULK_URL, headers=headers, json=data)
print (f'Status code: {response.status_code}')
pprint(response.json())
duration_data = response.json()
</code></pre>

출력 결과:

<pre><code class="python">{'data': [{'classes': [{'clips': [{'end': 14,
                                   'option': '',
                                   'prompt': 'cat',
                                   'score': 84.77,
                                   'start': 0},
                                  {'end': 14,
                                   'option': '',
                                   'prompt': 'pets doing funny things',
                                   'score': 83.56,
                                   'start': 8.34375},
                                  {'end': 14,
                                   'option': '',
                                   'prompt': 'playing with pets',
                                   'score': 68.11,
                                   'start': 8.34375},
                                  {'end': 8.34375,
                                   'option': '',
                                   'prompt': 'pets doing funny things',
                                   'score': 58.26,
                                   'start': 0}],
                        'duration_ratio': 1,
                        'name': 'AnimalTok',
                        'score': 95.35111111111111}],
			.
			.
			.	
           'video_id': '64544b8786daab572f349496'},
          {'classes': [{'clips': [{'end': 49.189,
                                   'option': '',
                                   'prompt': 'artists',
                                   'score': 78.14,
                                   'start': 0.55},
                                  {'end': 23.659,
                                   'option': '',
                                   'prompt': 'sketching',
                                   'score': 58.85,
                                   'start': 0.55}],
                        'duration_ratio': 0.8175611166393244,
                        'name': 'ArtTok',
                        'score': 89.45777777777778}],
			.
			.
			.
			'video_id': '64544b9286daab572f34949d'}],
 'page_info': {'limit_per_page': 10,
               'next_page_token': '',
               'page_expired_at': '2023-05-05T17:37:30Z',
               'prev_page_token': '',
               'total_results': 10}}
               </code></pre>

가독성을 위해 출력 일부를 축약했습니다. 이제 출력에 각 비디오의 클립 데이터가 나타나며, 세부적인 시작 및 종료 타임스탬프와 특정 클립 및 연관 프롬프트에 대한 신뢰도 점수가 어떻게 표시되는지 확인해 보세요. 저희는 각 프롬프트에 연결된 해당 분류 옵션(예: visual 및 conversation, 여기서 visual은 시각적 매치, conversation은 대화 매치를 나타냄)을 통합할 수 있도록 API 엔드포인트를 꾸준히 개선해 가고 있습니다.

이제 받아온 JSON 분류 데이터와 앞서 생성한 비디오 ID-이름 데이터 매핑 정보를 로컬 상에서 지속적으로 재활용할 수 있도록 직렬화(pickle)하여 로컬에 저장해 두겠습니다.

<pre><code class="python">import pickle

with open('video_id_name_list.pickle', 'wb') as handle:
    pickle.dump(video_id_name_list, handle, protocol=pickle.HIGHEST_PROTOCOL)

with open('duration_data.pickle', 'wb') as handle:
    pickle.dump(duration_data, handle, protocol=pickle.HIGHEST_PROTOCOL)

with open('clips_data.pickle', 'wb') as handle:
    pickle.dump(clips_data, handle, protocol=pickle.HIGHEST_PROTOCOL)
    </code></pre>

데모 앱 제작

이전 튜토리얼에서 진행했던 방식과 마찬가지로, 저장(직렬화)해 둔 인덱싱 정보들을 웹 페이지로 호스팅하여 매끄럽게 연동해 줄 Flask 기반의 유쾌한 데모 앱을 빌드해보겠습니다. 이 정돈된 데이터들을 바탕으로 로컬 드라이브의 실제 동영상들을 결합하여, 시각적으로 매력적이고 직관적인 웹 기반 분석 대시보드를 그리게 됩니다. 이를 통해 Twelve Labs의 동영상 분류 시스템이 애플리케이션에 가치를 더하고 완성도를 높여주는 과정을 직접 경험하실 수 있을 것입니다.

전체적인 디렉토리 구조는 다음과 같이 명확히 정의됩니다:

<pre><code class="markdown">my_flask_app/
│   app.py
│   sample_notebook.ipynb
└───templates/
│	│   index.html
└───classify/
	│   <your_video_1.mp4><your_video_2.mp4><your_video_3.mp4>
			.
			.
			.
</code></pre>

Flask 앱 코드

이번 가이드에서는 로컬의 특정 폴더에서 비디오 파일을 안전하게 가져와 재생하면서, HTML5 비디오 플레이어를 통해 타임스탬프 기반 의도된 시작지점 조각(클립)을 똑똑하게 지정 구동하는 조절 메커니즘을 살짝 녹였습니다. Flask 애플리케이션 내에 자리한 serve_video 함수가 실행 스크립트와 동일한 소스선상의 classify 디렉토리로부터 비디오 스트림을 웹 인터페이스에 올바르게 송출하고, HTML5 템플릿 상에 선언된 url_for('serve_video', filename=video_mapping[video.video_id]) 속성을 거치면서 브라우저 주소와 기민하게 결합하게 됩니다.

또한, "include_clips" 옵션 하에서 받아온 메타데이터에는 매우 유기적인 디테일이 살아있습니다. 다소 과도하고 분산되어 있을 수 있는 정보들을 한결 깔끔하고 응축된 뷰로 간소화하여 데모의 몰입도를 높이기 위해, 저는 get_top_clips 라는 보조 함수를 정의했습니다. 이 도구는 탐지된 키워드 맥락 중에서 가장 우수한 상위 3개의 고유 프롬프트 클립들만을 명확히 선별해내어, 한층 정돈되고 수준 높은 대시보드 구조를 제공합니다.

아래는 온전한 연동 메커니즘이 집약된 "app.py" 파일의 전체 코드 구성입니다:

<pre><code class="python">from flask import Flask, render_template, send_from_directory
import pickle
import os
from collections import defaultdict

app = Flask(__name__)

# Replace the following dictionaries with your data
with open('video_id_name_list.pickle', 'rb') as handle:
    video_id_name_list = pickle.load(handle)

with open('duration_data.pickle', 'rb') as handle:
    duration_data = pickle.load(handle)

with open('clips_data.pickle', 'rb') as handle:
    clips_data = pickle.load(handle)

VIDEO_DIRECTORY = os.path.join(os.path.dirname(os.path.realpath(__file__)), "classify")

@app.route('/<path:filename>')
def serve_video(filename):
    print(VIDEO_DIRECTORY, filename)
    return send_from_directory(directory=VIDEO_DIRECTORY, path=filename)

def get_top_clips(clips_data, num_clips=3):
    top_clips = defaultdict(list)
    for video in clips_data['data']:
        video_id = video['video_id']
        unique_prompts = set()
        for clip_class in video['classes']:
            for clip in clip_class['clips']:
                if clip['prompt'] not in unique_prompts and len(unique_prompts) < num_clips:
                    top_clips[video_id].append(clip)
                    unique_prompts.add(clip['prompt'])
    return top_clips

@app.route('/')
def home():
    video_id_name_dict = {video['video_id']: video['video_name'] for video in video_id_name_list}
    top_clips = get_top_clips(clips_data)
    return render_template('index.html', video_mapping=video_id_name_dict, duration_data=duration_data, top_clips=top_clips)


if __name__ == '__main__':
    app.run(debug=True)
</code></pre>

HTML 템플릿

아래는 Jinja2 템플릿 문법을 사용하여, 앞서 직렬화해 전달한 분석 데이터 세트를 화면상에 기민하게 반복 순회하며 표현하도록 짜인 HTML 명세서입니다. 이 마크업 단락은 로컬에 보관해 둔 비디오의 상영 구획을 끌어내고, 설계한 기준에 매칭된 분류 전경을 시각적 게이지바와 함께 미려하게 연출해 줍니다.

<pre><code class="language-html"><!doctype html>
<html lang="en">
<head>
    <link rel="shortcut icon" href="#" />
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Classification Output</title>
    <style>
        body {
            background-color: #f0e7cc;
            font-family: Arial, sans-serif;
        }
        .container {
            width: 80%;
            margin: 0 auto;
            padding-bottom: 30px;
        }
        video {
            display: block;
            margin: 0 auto;
        }
        .progress-bar {
            width: 20%;
            height: 20px;
            background-color: #f0f0f0;
            margin-bottom: 10px;
            display: inline-block;
            vertical-align: middle;
            margin-left: 10px;
        }
        .progress {
            height: 100%;
            background-color: #4caf50;
        }
        .score {
            display: inline-block;
            vertical-align: middle;
            font-weight: bold;
        }
        .video-category {
            padding-bottom: 30px;
            margin-bottom: 20px;
        }
        button {
            background-color: #008CBA;
            border: none;
            color: white;
            padding: 8px 16px;
            text-align: center;
            text-decoration: none;
            display: inline-block;
            font-size: 14px;
            margin: 4px 2px;
            cursor: pointer;
            border-radius: 4px;
            box-shadow: 0 1px 2px rgba(0,0,0,0.2);
            transition: background-color 0.2s ease;
        }
        button:hover {
            background-color: #006494;
        }
        h1 {
            color: #070707;
            text-align: center;
            background-color: #cb6a16; /* Change the background color to your desired color */
            padding: 10px;
            margin-bottom: 20px;  
        }
        .clips-container {
            text-align: center;
        }

    </style>
    <script>
        function playClip(videoPlayer, start, end) {
            videoPlayer.currentTime = start;
            videoPlayer.play();
            videoPlayer.ontimeupdate = function () {
                if (videoPlayer.currentTime >= end) {
                    videoPlayer.pause();
                }
            };
        }
    </script>
</head>
<body>
    <div class="container">
        <h1>Video Classification</h1>
        {% for video in duration_data.data %}
            {% set video_id = video.video_id %}
            {% set video_path = video_mapping[video_id] %}
            <div class="video-category">
                <h2>{{ video.classes[0].name }}</h2>
                <p>Video file: {{video_path}}</p>
                <div>
                    <span class="score">Score: {{ '%.2f'|format(video.classes[0].score) }}</span>
                    <div class="progress-bar">
                        <div class="progress" style="width: {{ video.classes[0].score }}%;"></div>
                    </div>
                </div>
                <div>
                    <span class="score">Duration: {{ '%.2f%%'|format(video.classes[0].duration_ratio * 100) }}</span>
                    <div class="progress-bar">
                        <div class="progress" style="width: {{ video.classes[0].duration_ratio * 100 }}%;"></div>
                    </div>
                </div>
                <video id="video-{{ video_id }}" width="480" height="320" controls>
                    <source src="{{ url_for('serve_video', filename=video_path) }}" type="video/mp4">
                    Your browser does not support the video tag.
                </video>
                <div class="clips-container">
                    <h3>Sample Clips</h3>
                    {% for clip in top_clips[video_id] %}
                    <button onclick="playClip(document.getElementById('video-{{ video_id }}'), {{ clip.start }}, {{ clip.end }})">Clip: {{ clip.prompt }} | Start {{ '%.2f'|format(clip.start) }}s - End {{ '%.2f'|format(clip.end) }}s - Score {{ '%.2f'|format(clip.score) }}</button>
                    {% endfor %}
            </div>
            <br /><br />
        {% endfor %}
    </div>
</body>
</html>
</code></pre>

Flask 앱 구동하기

훌륭합니다! 이제 Jupyter Notebook의 마지막 셀을 실행해 방금 설정한 Flask 앱을 정상 론칭시켜 보시죠:

<pre><code class="python">%run app.py
</code></pre>

모든 흐름이 예상대로 문제없이 완벽하게 귀결되는 것을 확인할 수 있을 것입니다 😊:

로컬 주소 링크 http://127.0.0.1:5000에 직접 진입해 주시면 다음과 같은 미려한 최종 연출 화면을 영접하게 됩니다:

본 완독 가이드에서 쓰인 모든 파이썬 스크립트 소스와 Jupyter Notebook의 전체 원형을 한눈에 살펴보실 수 있는 통합 보관 주소는 다음과 같습니다 - https://tinyurl.com/classifyNotebook

새로운 가능성을 실험해볼 준비가 되셨나요?

다음과 같은 흥미진진한 구상을 여러분의 비즈니스 도구에 도입해 보세요:

  1. 단순히 상위 3개의 힌트 샘플 단락만을 거르는 데 그치지 않고, 페이지네이션(Pagination) 및 지연 로딩(Lazy Loading) 기법을 멋지게 탑재하여 동영상에서 포착된 수많은 의미 조각 클립들을 빈틈없이 정방향 화면에 연동해 보세요. 이를 통해 사용자는 훨씬 넓은 반경의 카테고리화 대조군을 가시적으로 면밀히 심사하고 분석 전경을 전방위적으로 조망해낼 수 있습니다.

  2. 키워드 및 프롬프트의 의미 구성 방식(Short/Medium/Long-Tail)을 다변화해 보거나 임계치 설정을 유연하게 조절해가며 발견되는 현상 정보를 다채롭게 수집해 보세요. 얻어낸 귀중한 지식 통찰을 저희 Discord 미팅 채널을 통해 전 세계 멀티모달 기술 매니아 동료들과 함께 나누는 멋진 지적 축제를 누려보시길 권합니다.

마치며

앞으로 공개될 흥미진진한 소식들을 기대해 주시기 바랍니다! 아직 탐조등을 켜고 합류하지 않으셨다면, 멀티모달 인공지능의 개척 트렌드를 함께 호흡하고 유익을 도모하는 활기찬 Discord 커뮤니티의 문을 두드려 오손도손 이야기꽃을 피워보시길 진심으로 고대합니다.