튜토리얼

Twelve Labs API를 사용하여 비디오 OCR을 수행하는 방법은 무엇인가요?

안키트 카레 카레 (Ankit Khare)

Twelve Labs API를 사용하면 별도의 OCR 인프라를 구축하거나 유지할 필요 없이 비디오에서 화면 상의 텍스트를 추출하고, 색인화된 비디오 라이브러리 전체에서 텍스트 기반 검색을 수행할 수 있습니다. 이 튜토리얼에서는 비디오 수준의 텍스트 추출과 색인 수준의 비디오 내 텍스트 검색을 모두 다루며, 결과를 시각적으로 보여주는 Flask 앱 구현 방법까지 함께 소개합니다.

Twelve Labs API를 사용하면 별도의 OCR 인프라를 구축하거나 유지할 필요 없이 비디오에서 화면 상의 텍스트를 추출하고, 색인화된 비디오 라이브러리 전체에서 텍스트 기반 검색을 수행할 수 있습니다. 이 튜토리얼에서는 비디오 수준의 텍스트 추출과 색인 수준의 비디오 내 텍스트 검색을 모두 다루며, 결과를 시각적으로 보여주는 Flask 앱 구현 방법까지 함께 소개합니다.

목차

No headings found on page

뉴스레터 구독하기

뉴스레터 구독하기

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

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

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

2023. 5. 19.

13분

링크 복사하기

비디오 광학 문자 인식(OCR)은 컴퓨터 비전 및 머신러닝 알고리즘을 사용하여 비디오 프레임에서 텍스트를 감지하고 추출하는 기술입니다. 비디오 OCR을 사용하면 비디오 콘텐츠를 쉽게 훑어보며 특정 단어, 문구 또는 전체 문장이 화면에 나타나는 정확한 순간을 정확히 찾아낼 수 있습니다. 콘텐츠 검색 및 탐색 간소화부터 심층적인 콘텐츠 분석, 광고 게재 최적화, 콘텐츠 요약, SEO 극대화, 그리고 규정 준수 및 모니터링에 이르기까지 그 활용 분야를 상상해 보세요.

비디오 OCR을 통해 인식할 수 있는 요소의 예는 다음과 같습니다.

  • 프레젠테이션이나 회의 중 슬라이드 내용

  • 광고, 영화, TV 프로그램 등 화면에 노출되는 제품명

  • 스포츠 중계 중 유니폼에 정식 표기된 선수나 팀 이름

  • 회의나 컨퍼런스 중 식별 가능한 명찰 및 이름

  • 강의 비디오 내 화이트보드에 적힌 낙서나 필기

  • 비디오 촬영 화면 안에 잡힌 문서

  • 화면에 표시되는 손글씨 텍스트

  • 차량 번호판 및 건물 이름

  • 영화 및 인터뷰 내의 자막, 캡션, 엔딩 크레딧

이 튜토리얼에서는 Twelve Labs 플랫폼이 두 가지 고유한 수준에서 어떻게 비디오 OCR을 가능하게 하는지 살펴보겠습니다. 비디오 수준에서는 전체 비디오를 한 번에 처리하여 그 안에 담긴 모든 텍스트 요소를 활용합니다. 반면, 인덱스 수준 방식은 초점을 좁혀 특정 키워드나 키워드 클러스터에 집중하며, 이를 자연어 쿼리로 입력하여 Twelve Labs 플랫폼에 인덱싱된 비디오 라이브러리 전체에서 포괄적인 검색을 수행합니다.

가장 큰 장점은 무엇일까요? Twelve Labs API를 사용하면 OCR 프로세스를 구현하고 유지 관리하는 복잡한 과정에 대해 걱정할 필요 없이 이 모든 것을 달성할 수 있다는 점입니다. 개발부터 인프라, 그리고 지속적인 기술 지원까지 저희가 확실히 책임집니다. 이제 준비를 마치고 비디오 OCR 영역으로 펼쳐질 이 흥미진진한 여정을 함께 시작해 보겠습니다.

준비 사항

Twelve Labs 플랫폼은 현재 오픈 베타 단계에 있으며, 가입 시 최대 10시간 분량의 무료 비디오 인덱싱 크레딧을 제공합니다. 본 튜토리얼을 본격적으로 시작하기 전에 먼저 가입하여 Twelve Labs 플랫폼의 기본 개념들을 익혀두시면 큰 도움이 됩니다. 비디오 인덱싱, 인덱싱 옵션, Task API 및 검색 옵션에 대한 이해는 튜토리얼을 매끄럽게 따라오는 데 필수적이며, 이에 대한 상세한 내용은 저의 첫 번째 튜토리얼에서 광범위하게 다루었습니다. 진행 중 막히는 부분이 있거나 길을 잃은 듯한 느낌이 든다면 언제든 편하게 문의해 주세요. 참고로, 선호하시는 플랫폼이 Discord라면 저희 Discord 서버에서의 답변 속도는 정말 빠릅니다 🚅🏎️⚡️.

튜토리얼 미리보기

이전 담론에 이어, 두 가지 고유한 관점과 수준에서 비디오 OCR 문제를 해결하는 방법을 알아봅니다. 이에 따라 본 튜토리얼을 두 개의 핵심 섹션으로 나누었으며, 마지막에는 모든 요소를 결합해 실제로 작동하는 데모 웹 앱을 만들어 피날레를 장식합니다.

비디오 OCR - 3단계 프로세스

특정 비디오에서 인식된 모든 텍스트를 추출하는 과정은 다음 세 단계로 수행됩니다.

  • 비디오 인덱싱 - 낯설지 않은 단계입니다. 이전 튜토리얼들을 잘 따라오셨다면 이 단계는 친숙한 친구처럼 느껴질 것입니다.

  • 비디오 고유 식별자 가져오기 - Twelve Labs 플랫폼이 비디오 인덱싱을 마치면 OCR을 적용하고자 하는 비디오의 고유 식별자를 가져옵니다.

  • 화면에 나타나는 텍스트 추출하기 - 생성한 특정 인덱스와 OCR 대상 비디오에 연결된 비디오 ID를 사용하여 비디오를 지정합니다. 이후 API가 번거로운 작업을 대신 수행하여 원하는 결과를 제공합니다.

비디오 내 텍스트 검색 - 인덱싱된 모든 비디오 내에서 특정 텍스트 검색

비디오 OCR을 통해 전체 비디오를 면밀히 분석하고 텍스트가 나타나는 모든 순간을 추출할 수 있었습니다. 이제 '비디오 내 텍스트 검색(text-in-video search)' 기능을 사용하면 입력하거나 검색한 텍스트가 실제로 구현되는 정확한 순간 또는 비디오 클립을 정밀하게 타겟팅할 수 있습니다. 이를 통해 방대한 비디오 카탈로그를 직접 훑어보는 데 소요되는 시간을 크게 단축할 수 있으며, 비디오 재생 중 화면에 노출되는 텍스트와 검색어 간의 일치도를 기반으로 정확한 검색 결과를 산출합니다.

첫 번째 튜토리얼에서는 자연어 쿼리와 비주얼(시청각 검색), 컨버세이션(대화 검색), 비디오 내 텍스트(OCR) 등 다양한 검색 옵션을 사용하여 인덱싱된 비디오 내의 콘텐츠 검색을 깊이 있게 다루었습니다. 이번 튜토리얼에서는 이 접근 방식을 재구성하여 오직 OCR 기술만을 활용해 비디오 내 텍스트를 검색해 보겠습니다. 처리 시간과 비용을 최적화하기 위해 오직 text_in_video 인덱싱 옵션만을 사용하여 인덱스를 생성할 것입니다. 그런 다음 text_in_video 검색 옵션으로 검색 쿼리를 실행하여 인덱싱된 비디오 내에서 관련 텍스트 일치 항목을 찾아냅니다.

데모 앱 구축

모든 결과를 종합하기 위해 API 엔드포인트에서 생성된 데이터를 웹페이지에 표시하고, 심플한 HTML 페이지를 제공하는 Flask 기반의 데모 앱을 빠르게 구동해 보겠습니다. 비디오 OCR 결과는 타임스탬프와 관련 텍스트가 표 형태로 깔끔하게 정리되어 표시되며, 텍스트 검색 영역에는 사용한 쿼리와 이에 대응하여 찾아낸 관련 비디오 세그먼트가 표시됩니다.

비디오 OCR - 3단계 프로세스

이해를 돕기 위해 기존 계정을 사용하여 인덱스에 단 두 개의 비디오만 업로드해 두었습니다. 가입은 언제든 환영합니다. 현재 오픈 베타 단계이므로 최대 10시간 분량의 비디오 콘텐츠를 인덱싱할 수 있는 무료 크레딧을 받으실 수 있습니다. 그 이상의 지원이 필요하시다면 요금제 페이지를 확인하시고 디벨로퍼(Developer) 플랜으로 업그레이드해 보세요.

비디오 인덱싱

여기서는 Jupyter 노트북에 포함해야 할 에센셜 요소들을 깊이 있게 다룹니다. 필요한 라이브러리 임포트, API URL 정의, 인덱스 생성, 인덱싱 프로세스를 시작하기 위해 로컬 파일 시스템에서 비디오를 업로드하는 과정 등이 포함됩니다.

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

!pip install requests

import os
import requests
import glob
from pprint import pprint

# Retrieve the URL of the API and my 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 = create_index(index_name = "extract_text", index_options=["text_in_video"], engine = "marengo2.5")

# Print the created index IDs
print(f"Created index IDs: {INDEX_ID}")
  </code></pre>

방금 생성한 인덱스에 두 개의 비디오를 업로드합니다. 비디오 제목은 'A Brief History of Film'(Film Thought Project 제공, 주소: https://www.youtube.com/watch?v=utntGgcsZWI)과 'GPT - Explained!'(CodeEmporium 제공, 주소: https://www.youtube.com/watch?v=3IweGfgytgY)입니다. 필자는 이 비디오들을 각각의 YouTube 채널에서 다운로드하여 로컬 하드 드라이브의 'static' 폴더에 저장했습니다. 이 로컬 파일들을 사용하여 Twelve Labs 플랫폼에 비디오를 인덱싱해 보겠습니다.

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

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

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>

비디오 고유 식별자 가져오기

이제 인덱스 내의 모든 비디오를 열거해 보겠습니다. 이를 통해 특정 비디오의 ID를 보유하고 그 안에 삽입된 모든 텍스트를 추출하는 것을 목표로 합니다. 나아가, 이전 튜토리얼의 방식과 유사하게 Flask 애플리케이션에 전달할 목적으로 비디오 ID와 각각의 타이틀 목록을 구성하고 있습니다.

<pre><code class="python"># List all the videos in an index
default_header = {
    "x-api-key": API_KEY
}
INDEX_ID='644a73aa8b1dd6cde172a933'
INDEXES_VIDEOS_URL = f"{API_URL}/indexes/{INDEX_ID}/videos"
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': '###a917186daab572f349243',
           'created_at': '2023-04-27T14:18:48Z',
           'metadata': {'duration': 1300.173875,
                        'engine_id': 'marengo2.5',
                        'filename': 'A Brief History of Film.mp4',
                        'fps': 23.976023976023978,
                        'height': 720,
                        'size': 188214297,
                        'width': 1280},
           'updated_at': '2023-04-27T14:20:11Z'},
          {'_id': '###3da86daab572f349241',
           'created_at': '2023-04-27T13:08:19Z',
           'metadata': {'duration': 550.7,
                        'engine_id': 'marengo2.5',
                        'filename': 'GPT - Explained!.mp4',
                        'fps': 30,
                        'height': 720,
                        'size': 22838593,
                        'width': 1152},
           'updated_at': '2023-04-27T13:08:42Z'}],
 'page_info': {'limit_per_page': 10,
               'page': 1,
               'total_duration': 5402.873875,
               'total_page': 1,
               'total_results': 3}}

[{'video_id': '###a849b86daab572f349242',
  'video_name': 'A Brief History of Film.mp4'},
 {'video_id': '###a73da86daab572f349241', 'video_name': 'GPT - Explained!.mp4'}]
   </code></pre>

화면에 나타나는 텍스트 추출하기

설계한 계획을 실행에 옮길 시간입니다! 이제 선택한 비디오에서 모든 텍스트 콘텐츠를 추출해 보겠습니다.

<pre><code class="python">VIDEO_ID = '###a849b86daab572f349242'
TEXT_IN_VIDEO_URL = f"{API_URL}/indexes/{INDEX_ID}/videos/{VIDEO_ID}/text-in-video"

response = requests.get(TEXT_IN_VIDEO_URL, headers=default_header)
print (f"Status code: {response.status_code}")
ocr_data = response.json()
pprint (ocr_data)
  </code></pre>

출력 결과:

<pre><code class="python">Status code: 200
{'data': [{'end': 3, 'start': 1, 'value': 'Film Thought Project'},
          {'end': 6, 'start': 5, 'value': 'Film'},
          {'end': 22,
           'start': 18,
           'value': "'L'arrivée d'un train en gare de La Ciotat"},
          {'end': 28, 'start': 18, 'value': 'Year:'},
          {'end': 28, 'start': 23, 'value': '2015'},
          {'end': 28, 'start': 23, 'value': 'Production Co.'},
          {'end': 28, 'start': 23, 'value': 'Alejandro G. Iñárritu'},
          {'end': 28, 'start': 23, 'value': 'Regency Enterprises'},
          {'end': 28, 'start': 23, 'value': "'The Revenant'"},
          {'end': 30, 'start': 29, 'value': "Let's"},
          {'end': 40, 'start': 32, 'value': 'Film:'},
          {'end': 34, 'start': 33, 'value': 'Film Thought Project'},
          {'end': 40, 'start': 35, 'value': 'Director:'},
          {'end': 40, 'start': 35, 'value': 'Production Co.'},
          {'end': 40, 'start': 36, 'value': 'Alfred Hitchcock'},
          {'end': 40, 'start': 36, 'value': '1958'},
          {'end': 40, 'start': 36, 'value': 'Alfred J. Hitchcock Productions'},
          {'end': 40, 'start': 37, 'value': 'Year:'},
          {'end': 40, 'start': 38, 'value': "'Vertigo'"},
          {'end': 45, 'start': 44, 'value': 'PRESS START'},
          {'end': 46, 'start': 45, 'value': '2020'},
          {'end': 47, 'start': 46, 'value': '2018'},
          {'end': 48, 'start': 47, 'value': '1975'},
          {'end': 53, 'start': 49, 'value': '1870s'},
          {'end': 61, 'start': 67, 'value': 'Eadweard Muybridge'},
          {'end': 69, 'start': 75, 'value': 'See you soon'}],
 'id': '###a849b86daab572f349242',
 'index_id': '###a73aa8b1dd6cde172a933'}
 </code></pre>

보시다시피 API는 놀라울 정도로 깔끔하게 화면상의 모든 텍스트를 라인별로 정환하게 추출해냈습니다. 이 텍스트들을 메타데이터로 저장해 두면 향후 콘텐츠 분류, 필터링, 정밀 검색 등 다양한 하위 워크플로우에 유용하게 연계할 수 있습니다.

비디오 내 텍스트 검색 - 인덱싱된 모든 비디오 내에서 특정 텍스트 검색

인덱싱된 비디오 컬렉션 내에서 적절한 텍스트 매칭 결과를 확보하기 위해 text_in_video 검색 옵션을 활용하여 검색 쿼리를 실행해 보겠습니다.

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

# Declare a dictionary named `data`
data = {
    "index_id": INDEX_ID,
    "query": "horse",
    "search_options": [
        "text_in_video"
    ]
}
               
# Make a search request
response = requests.post(SEARCH_URL, headers=default_header, json=data)
if response.status_code == 200:
    print(f"Status code: {response.status_code} - Success")
else:
    print(f"Status code: {response.status_code}")
pprint(response.json())
search_data = response.json()
</code></pre>

출력 결과:

<pre><code class="python">Status code: 200 - Success
{'data': [{'confidence': 'high',
           'end': 64,
           'metadata': [{'text': 'THE HORSE IN MOTION.',
                         'type': 'text_in_video'}],
           'score': 92.28,
           'start': 63,
           'video_id': '###a849b86daab572f349242'},
          {'confidence': 'high',
           'end': 91,
           'metadata': [{'text': 'THE HORSE IN MOTION.',
                         'type': 'text_in_video'}],
           'score': 92.28,
           'start': 88,
           'video_id': '###a849b86daab572f349242'}],
 'page_info': {'limit_per_page': 10,
               'page_expired_at': '2023-05-12T00:03:43Z',
               'total_results': 2},
 'search_pool': {'index_id': '###a73aa8b1dd6cde172a933',
                 'total_count': 3,
                 'total_duration': 5403}}
                 </code></pre>

💡 비디오 내 텍스트 검색 기능은 비디오 재생 중 화면에 시각적으로 표시되는 텍스트가 입력한 검색 쿼리와 일치하는(반드시 토씨 하나 틀리지 않고 일치할 필요는 없음) 인덱싱된 비디오 내의 모든 발생 지점을 파악하도록 설계되어 있습니다. 예를 들어, 제가 "horse moving"을 입력하면 시스템은 화면에 표시된 텍스트가 "horse in motion"인 지점을 식별해 냅니다. 단, 이때의 신뢰도(Confidence level) 평가는 "horse in motion"을 그대로 입력했을 때보다 상대적으로 낮게 나옵니다. 이 신뢰도는 저희가 입력한 자연어 쿼리와 실제 매칭되는 단어 비율에 따라 결정됩니다. 에컨대 3개 단어 중 2개 단어가 맞물렸을 경우가 오직 1개 단어만 일치했을 때보다 더 높은 신뢰도를 얻게 됩니다.

특정 검색어에 대한 Twelve Labs 플레이그라운드의 비디오 내 텍스트 검색 결과 화면 예시

입력한 쿼리에 부합하여 재생되고 있는 특정 비디오 구간

검색 쿼리가 화면상 텍스트에 부합할수록 모델의 신뢰도가 즉각 상승합니다.

결과가 웹브라우저에서 깔끔하게 보일 수 있도록 Flask 애플리케이션용 데이터를 준비하는 단계입니다.

<pre><code class="python">video_data = [{'start': d['start'], 'end': d['end'], 'confidence': d['confidence'], 'text': d['metadata'][0]['text']} for d in search_data['data']]
video_search_dict = {}

for vd in video_data:
    if search_data['data'][0]['video_id'] in video_search_dict:
        video_search_dict[search_data['data'][0]['video_id']].append(vd)
    else:
        video_search_dict[search_data['data'][0]['video_id']] = [vd]

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

출력 결과:

<pre><code class="python">
{'###a849b86daab572f349242': [{'confidence': 'high',
                               'end': 64,
                               'start': 63,
                               'text': 'THE HORSE IN MOTION.'},
                              {'confidence': 'high',
                               'end': 91,
                               'start': 88,
                               'text': 'THE HORSE IN MOTION.'}]}
</code></pre>

비디오 OCR 결과를 위해 데이터를 좀 더 정리한 다음, 준비한 모든 것을 피클(pickle) 파일로 직렬화하여 영속화하는 작업을 거칩니다.

<pre><code class="python">video_id = ocr_data.get('id')
data_list = ocr_data.get('data')

data_to_save = {
    'video_id': video_id,
    'data_list': data_list,
    'video_id_name_list': video_id_name_list,
    'video_search_dict': video_search_dict
}

import pickle

# Save data to a pickle file
with open('data.pkl', 'wb') as f:
    pickle.dump(data_to_save, f)
    </code></pre>

데모 앱 구축

이제 비디오 OCR 탐험의 마지막 단계에 도달했습니다. 모든 요소를 결합해 대화형 결과를 도출해 보겠습니다. 로컬 폴더에서 비디오를 가져오고 Jupyter 노트북에서 내보낸 피클 데이터를 로드하는 표준적인 구성 외에도, 이번에는 소수점 아래 초 단위 포맷의 타임스탬프를 직관적인 분:초 포맷으로 변환하는 추가 연산 과정이 포함됩니다. 이렇게 하면 웹페이지 상의 데이터 시각화가 훨씬 매끄럽고 명확해집니다. 아래 코드는 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__)

# Load data from a pickle file
with open('data.pkl', 'rb') as f:
    loaded_data = pickle.load(f)

# Access the data
video_id = loaded_data['video_id']
data_list = loaded_data['data_list']
video_id_name_list = loaded_data['video_id_name_list']
video_search_dict = loaded_data['video_search_dict']

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

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

@app.route('/')
def home():
    for item in data_list:
        if ":" not in str(item['start']):
            item['start'] = int(item['start'])
            item['start'] = f"{item['start'] // 60}:{item['start'] % 60:02}"
        if ":" not in str(item['end']):
            item['end'] = int(item['end'])
            item['end'] = f"{item['end'] // 60}:{item['end'] % 60:02}"


    video_id_name_dict = {video['video_id']: video['video_name'] for video in video_id_name_list}
    # video_name = video_id_name_dict.get(video_id)
    return render_template('index.html', data=data_list[:10], video_id_name_dict=video_id_name_dict, video_id=video_id, video_search_dict = video_search_dict)

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

HTML 템플릿

이제 남은 마지막 조각인 Jinja-2 기반 HTML 템플릿 코드를 작성할 시간입니다. 이 템플릿은 Flask app.py 파일을 통해 인가된 모든 데이터를 수신하여 렌더링합니다. 우리의 기본 목표는 일차적으로 비디오 OCR 추출 결과를 가독성 있게 구조화해 노출하는 것입니다. 상단의 비디오 플레이어는 전체 동영상을 커버하며, 플레이어 하단에는 비디오 내 특정 구간에서 추출된 시작 시간, 종료 시간 및 텍스트 캡션이 표 형태로 구성됩니다. 명확성을 높이기 위해 타임스탬프는 분:초 형태로 사용자에게 표시되며, 링크를 클릭하면 해당 분:초 구간으로 재생 헤더가 즉각 이동하게 됩니다. 단, JavaScript의 playVideo 타임라인 탐색 함수는 초 단위 단위 정형 입력을 요구하므로, 링크 이벤트 인자 전송 함수를 거칠 때만 다시 초 형태의 정형 상태로 환원 처리해 전달합니다.

<pre><code class="language-html"><!DOCTYPE html>
<html>
<head>
    <link rel="shortcut icon" href="#" />
    <title>Video OCR</title>
    <style>
        body {
            text-align: center;
            font-family: Arial, sans-serif;
            color: #333;
            background-color: #f5f5f5;
        }
        h1, h2 {
            color: #444;
        }
        table {
            margin: 0 auto;
            border-collapse: collapse;
            width: 80%;
            margin-top: 20px;
        }
        th, td {
            border: 1px solid #ddd;
            padding: 8px;
            text-align: center;
        }
        th {
            padding-top: 12px;
            padding-bottom: 12px;
            text-decoration: underline;
            color: black;
        }
        video {
            width: 40%;
            height: auto;
            margin-top: 20px;
        }

        /* search style */
        .video-container {
            text-align: center;
            margin-bottom: 2em;
            padding: 1em;
            background-color: #fff;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        table {
            margin: 0 auto;
            margin-bottom: 1em;
        }
        th, td {
            padding: 0.5em;
            border: 1px solid #ddd;
        }
    </style>
    <script>
        function playVideo(timeString) {
            var timeParts = timeString.split(":");
            var time = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1]);
            var video = document.querySelector('#mainVideo');
            video.currentTime = time;
            video.play();
        }
    </script>    
</head>

<body>
    <h1>Video OCR</h1>
    <h3>Video file: <i>{{ video_id_name_dict[video_id]}}</i></h3>
    <video id="mainVideo" controls>
        <source src="{{ url_for('static', filename=video_id_name_dict[video_id]|string) }}" type="video/mp4">
        Your browser does not support the video tag.
    </video>
    <br /> <br /> <br />
    <table>
        <tr>
            <th>Start</th>
            <th>End</th>
            <th>Value</th>
        </tr>
        {% for item in data %}
        <tr>
            <td><a href="javascript:void(0)" onclick="playVideo('{{ item['start'] }}')">{{ item['start'] }}</a></td>
            <td>{{ item['end'] }}</td>
            <td>{{ item['value'] }}</td>
        </tr>
        {% endfor %}
    </table>
    <br /> <br />
      
    {% for video_id, results in video_search_dict.items() %}
    <div class="video-container">
        <h1>Text-in-video Search Results</h1>  
        <h2>Video file: <i>{{ video_id_name_dict[video_id] }}</i></h2>
        <h2>Entered query: <i>{{input_query}}</i></h2>
        {% for result in results %}
        <video controls preload="metadata" style="width: 40%;">
            <source src="{{ url_for('static', filename=video_id_name_dict[video_id]) }}#t={{ result['start'] }},{{ result['end'] }}" type="video/mp4">
            Your browser does not support the video tag.
        </video>
        <table>
            <tr>
                <th>Start</th>
                <th>End</th>
                <th>Confidence</th>
                <th>Text</th>
            </tr>
            <tr>
                <td>{{ result['start'] }}</td>
                <td>{{ result['end'] }}</td>
                <td>{{ result['confidence'] }}</td>
                <td>{{ result['text'] }}</td>
            </tr>
        </table>
        {% endfor %}
    </div>
    {% endfor %}
</body>
</html>
</code></pre>

Flask 앱 실행하기

훌륭합니다! 이제 Jupyter 노트북의 마지막 셀을 실행하여 Flask 앱을 시작해 보겠습니다.

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

모든 과정이 정상적으로 설계대로 작동했음을 명확히 확인시켜 주는 다음과 유사한 출력이 표시될 것입니다 😊:

활성화된 URL 링크 주소 http://127.0.0.1:5000를 클릭하고 나면, 웹브라우저 창을 통해 다음과 같이 깔끔하게 정렬된 인터페이스 페이지가 열립니다.

여기에 본 튜토리얼을 통해 함께 작성한 전체 코드가 들어 있는 Jupyter Notebook 파일 경로가 있습니다 - https://drive.google.com/drive/folders/1D97_UU2Z0lvp3y52BHV5GKkSNOQKv3Xi?usp=share_link

맺음말

앞으로도 더욱 유익하고 알찬 모범 사례 콘텐츠들이 활발하게 업데이트될 예정입니다. 아직 참여하지 않으셨다면, 멀티모달 AI 기술에 애정을 지닌 활발한 동료 개발자들이 한데 모여 활기차게 소통하고 있는 마당인 저희 Discord 커뮤니티에 합류해 즐거운 대화를 이어가 보시길 진심으로 제안합니다.

비디오 광학 문자 인식(OCR)은 컴퓨터 비전 및 머신러닝 알고리즘을 사용하여 비디오 프레임에서 텍스트를 감지하고 추출하는 기술입니다. 비디오 OCR을 사용하면 비디오 콘텐츠를 쉽게 훑어보며 특정 단어, 문구 또는 전체 문장이 화면에 나타나는 정확한 순간을 정확히 찾아낼 수 있습니다. 콘텐츠 검색 및 탐색 간소화부터 심층적인 콘텐츠 분석, 광고 게재 최적화, 콘텐츠 요약, SEO 극대화, 그리고 규정 준수 및 모니터링에 이르기까지 그 활용 분야를 상상해 보세요.

비디오 OCR을 통해 인식할 수 있는 요소의 예는 다음과 같습니다.

  • 프레젠테이션이나 회의 중 슬라이드 내용

  • 광고, 영화, TV 프로그램 등 화면에 노출되는 제품명

  • 스포츠 중계 중 유니폼에 정식 표기된 선수나 팀 이름

  • 회의나 컨퍼런스 중 식별 가능한 명찰 및 이름

  • 강의 비디오 내 화이트보드에 적힌 낙서나 필기

  • 비디오 촬영 화면 안에 잡힌 문서

  • 화면에 표시되는 손글씨 텍스트

  • 차량 번호판 및 건물 이름

  • 영화 및 인터뷰 내의 자막, 캡션, 엔딩 크레딧

이 튜토리얼에서는 Twelve Labs 플랫폼이 두 가지 고유한 수준에서 어떻게 비디오 OCR을 가능하게 하는지 살펴보겠습니다. 비디오 수준에서는 전체 비디오를 한 번에 처리하여 그 안에 담긴 모든 텍스트 요소를 활용합니다. 반면, 인덱스 수준 방식은 초점을 좁혀 특정 키워드나 키워드 클러스터에 집중하며, 이를 자연어 쿼리로 입력하여 Twelve Labs 플랫폼에 인덱싱된 비디오 라이브러리 전체에서 포괄적인 검색을 수행합니다.

가장 큰 장점은 무엇일까요? Twelve Labs API를 사용하면 OCR 프로세스를 구현하고 유지 관리하는 복잡한 과정에 대해 걱정할 필요 없이 이 모든 것을 달성할 수 있다는 점입니다. 개발부터 인프라, 그리고 지속적인 기술 지원까지 저희가 확실히 책임집니다. 이제 준비를 마치고 비디오 OCR 영역으로 펼쳐질 이 흥미진진한 여정을 함께 시작해 보겠습니다.

준비 사항

Twelve Labs 플랫폼은 현재 오픈 베타 단계에 있으며, 가입 시 최대 10시간 분량의 무료 비디오 인덱싱 크레딧을 제공합니다. 본 튜토리얼을 본격적으로 시작하기 전에 먼저 가입하여 Twelve Labs 플랫폼의 기본 개념들을 익혀두시면 큰 도움이 됩니다. 비디오 인덱싱, 인덱싱 옵션, Task API 및 검색 옵션에 대한 이해는 튜토리얼을 매끄럽게 따라오는 데 필수적이며, 이에 대한 상세한 내용은 저의 첫 번째 튜토리얼에서 광범위하게 다루었습니다. 진행 중 막히는 부분이 있거나 길을 잃은 듯한 느낌이 든다면 언제든 편하게 문의해 주세요. 참고로, 선호하시는 플랫폼이 Discord라면 저희 Discord 서버에서의 답변 속도는 정말 빠릅니다 🚅🏎️⚡️.

튜토리얼 미리보기

이전 담론에 이어, 두 가지 고유한 관점과 수준에서 비디오 OCR 문제를 해결하는 방법을 알아봅니다. 이에 따라 본 튜토리얼을 두 개의 핵심 섹션으로 나누었으며, 마지막에는 모든 요소를 결합해 실제로 작동하는 데모 웹 앱을 만들어 피날레를 장식합니다.

비디오 OCR - 3단계 프로세스

특정 비디오에서 인식된 모든 텍스트를 추출하는 과정은 다음 세 단계로 수행됩니다.

  • 비디오 인덱싱 - 낯설지 않은 단계입니다. 이전 튜토리얼들을 잘 따라오셨다면 이 단계는 친숙한 친구처럼 느껴질 것입니다.

  • 비디오 고유 식별자 가져오기 - Twelve Labs 플랫폼이 비디오 인덱싱을 마치면 OCR을 적용하고자 하는 비디오의 고유 식별자를 가져옵니다.

  • 화면에 나타나는 텍스트 추출하기 - 생성한 특정 인덱스와 OCR 대상 비디오에 연결된 비디오 ID를 사용하여 비디오를 지정합니다. 이후 API가 번거로운 작업을 대신 수행하여 원하는 결과를 제공합니다.

비디오 내 텍스트 검색 - 인덱싱된 모든 비디오 내에서 특정 텍스트 검색

비디오 OCR을 통해 전체 비디오를 면밀히 분석하고 텍스트가 나타나는 모든 순간을 추출할 수 있었습니다. 이제 '비디오 내 텍스트 검색(text-in-video search)' 기능을 사용하면 입력하거나 검색한 텍스트가 실제로 구현되는 정확한 순간 또는 비디오 클립을 정밀하게 타겟팅할 수 있습니다. 이를 통해 방대한 비디오 카탈로그를 직접 훑어보는 데 소요되는 시간을 크게 단축할 수 있으며, 비디오 재생 중 화면에 노출되는 텍스트와 검색어 간의 일치도를 기반으로 정확한 검색 결과를 산출합니다.

첫 번째 튜토리얼에서는 자연어 쿼리와 비주얼(시청각 검색), 컨버세이션(대화 검색), 비디오 내 텍스트(OCR) 등 다양한 검색 옵션을 사용하여 인덱싱된 비디오 내의 콘텐츠 검색을 깊이 있게 다루었습니다. 이번 튜토리얼에서는 이 접근 방식을 재구성하여 오직 OCR 기술만을 활용해 비디오 내 텍스트를 검색해 보겠습니다. 처리 시간과 비용을 최적화하기 위해 오직 text_in_video 인덱싱 옵션만을 사용하여 인덱스를 생성할 것입니다. 그런 다음 text_in_video 검색 옵션으로 검색 쿼리를 실행하여 인덱싱된 비디오 내에서 관련 텍스트 일치 항목을 찾아냅니다.

데모 앱 구축

모든 결과를 종합하기 위해 API 엔드포인트에서 생성된 데이터를 웹페이지에 표시하고, 심플한 HTML 페이지를 제공하는 Flask 기반의 데모 앱을 빠르게 구동해 보겠습니다. 비디오 OCR 결과는 타임스탬프와 관련 텍스트가 표 형태로 깔끔하게 정리되어 표시되며, 텍스트 검색 영역에는 사용한 쿼리와 이에 대응하여 찾아낸 관련 비디오 세그먼트가 표시됩니다.

비디오 OCR - 3단계 프로세스

이해를 돕기 위해 기존 계정을 사용하여 인덱스에 단 두 개의 비디오만 업로드해 두었습니다. 가입은 언제든 환영합니다. 현재 오픈 베타 단계이므로 최대 10시간 분량의 비디오 콘텐츠를 인덱싱할 수 있는 무료 크레딧을 받으실 수 있습니다. 그 이상의 지원이 필요하시다면 요금제 페이지를 확인하시고 디벨로퍼(Developer) 플랜으로 업그레이드해 보세요.

비디오 인덱싱

여기서는 Jupyter 노트북에 포함해야 할 에센셜 요소들을 깊이 있게 다룹니다. 필요한 라이브러리 임포트, API URL 정의, 인덱스 생성, 인덱싱 프로세스를 시작하기 위해 로컬 파일 시스템에서 비디오를 업로드하는 과정 등이 포함됩니다.

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

!pip install requests

import os
import requests
import glob
from pprint import pprint

# Retrieve the URL of the API and my 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 = create_index(index_name = "extract_text", index_options=["text_in_video"], engine = "marengo2.5")

# Print the created index IDs
print(f"Created index IDs: {INDEX_ID}")
  </code></pre>

방금 생성한 인덱스에 두 개의 비디오를 업로드합니다. 비디오 제목은 'A Brief History of Film'(Film Thought Project 제공, 주소: https://www.youtube.com/watch?v=utntGgcsZWI)과 'GPT - Explained!'(CodeEmporium 제공, 주소: https://www.youtube.com/watch?v=3IweGfgytgY)입니다. 필자는 이 비디오들을 각각의 YouTube 채널에서 다운로드하여 로컬 하드 드라이브의 'static' 폴더에 저장했습니다. 이 로컬 파일들을 사용하여 Twelve Labs 플랫폼에 비디오를 인덱싱해 보겠습니다.

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

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

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>

비디오 고유 식별자 가져오기

이제 인덱스 내의 모든 비디오를 열거해 보겠습니다. 이를 통해 특정 비디오의 ID를 보유하고 그 안에 삽입된 모든 텍스트를 추출하는 것을 목표로 합니다. 나아가, 이전 튜토리얼의 방식과 유사하게 Flask 애플리케이션에 전달할 목적으로 비디오 ID와 각각의 타이틀 목록을 구성하고 있습니다.

<pre><code class="python"># List all the videos in an index
default_header = {
    "x-api-key": API_KEY
}
INDEX_ID='644a73aa8b1dd6cde172a933'
INDEXES_VIDEOS_URL = f"{API_URL}/indexes/{INDEX_ID}/videos"
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': '###a917186daab572f349243',
           'created_at': '2023-04-27T14:18:48Z',
           'metadata': {'duration': 1300.173875,
                        'engine_id': 'marengo2.5',
                        'filename': 'A Brief History of Film.mp4',
                        'fps': 23.976023976023978,
                        'height': 720,
                        'size': 188214297,
                        'width': 1280},
           'updated_at': '2023-04-27T14:20:11Z'},
          {'_id': '###3da86daab572f349241',
           'created_at': '2023-04-27T13:08:19Z',
           'metadata': {'duration': 550.7,
                        'engine_id': 'marengo2.5',
                        'filename': 'GPT - Explained!.mp4',
                        'fps': 30,
                        'height': 720,
                        'size': 22838593,
                        'width': 1152},
           'updated_at': '2023-04-27T13:08:42Z'}],
 'page_info': {'limit_per_page': 10,
               'page': 1,
               'total_duration': 5402.873875,
               'total_page': 1,
               'total_results': 3}}

[{'video_id': '###a849b86daab572f349242',
  'video_name': 'A Brief History of Film.mp4'},
 {'video_id': '###a73da86daab572f349241', 'video_name': 'GPT - Explained!.mp4'}]
   </code></pre>

화면에 나타나는 텍스트 추출하기

설계한 계획을 실행에 옮길 시간입니다! 이제 선택한 비디오에서 모든 텍스트 콘텐츠를 추출해 보겠습니다.

<pre><code class="python">VIDEO_ID = '###a849b86daab572f349242'
TEXT_IN_VIDEO_URL = f"{API_URL}/indexes/{INDEX_ID}/videos/{VIDEO_ID}/text-in-video"

response = requests.get(TEXT_IN_VIDEO_URL, headers=default_header)
print (f"Status code: {response.status_code}")
ocr_data = response.json()
pprint (ocr_data)
  </code></pre>

출력 결과:

<pre><code class="python">Status code: 200
{'data': [{'end': 3, 'start': 1, 'value': 'Film Thought Project'},
          {'end': 6, 'start': 5, 'value': 'Film'},
          {'end': 22,
           'start': 18,
           'value': "'L'arrivée d'un train en gare de La Ciotat"},
          {'end': 28, 'start': 18, 'value': 'Year:'},
          {'end': 28, 'start': 23, 'value': '2015'},
          {'end': 28, 'start': 23, 'value': 'Production Co.'},
          {'end': 28, 'start': 23, 'value': 'Alejandro G. Iñárritu'},
          {'end': 28, 'start': 23, 'value': 'Regency Enterprises'},
          {'end': 28, 'start': 23, 'value': "'The Revenant'"},
          {'end': 30, 'start': 29, 'value': "Let's"},
          {'end': 40, 'start': 32, 'value': 'Film:'},
          {'end': 34, 'start': 33, 'value': 'Film Thought Project'},
          {'end': 40, 'start': 35, 'value': 'Director:'},
          {'end': 40, 'start': 35, 'value': 'Production Co.'},
          {'end': 40, 'start': 36, 'value': 'Alfred Hitchcock'},
          {'end': 40, 'start': 36, 'value': '1958'},
          {'end': 40, 'start': 36, 'value': 'Alfred J. Hitchcock Productions'},
          {'end': 40, 'start': 37, 'value': 'Year:'},
          {'end': 40, 'start': 38, 'value': "'Vertigo'"},
          {'end': 45, 'start': 44, 'value': 'PRESS START'},
          {'end': 46, 'start': 45, 'value': '2020'},
          {'end': 47, 'start': 46, 'value': '2018'},
          {'end': 48, 'start': 47, 'value': '1975'},
          {'end': 53, 'start': 49, 'value': '1870s'},
          {'end': 61, 'start': 67, 'value': 'Eadweard Muybridge'},
          {'end': 69, 'start': 75, 'value': 'See you soon'}],
 'id': '###a849b86daab572f349242',
 'index_id': '###a73aa8b1dd6cde172a933'}
 </code></pre>

보시다시피 API는 놀라울 정도로 깔끔하게 화면상의 모든 텍스트를 라인별로 정환하게 추출해냈습니다. 이 텍스트들을 메타데이터로 저장해 두면 향후 콘텐츠 분류, 필터링, 정밀 검색 등 다양한 하위 워크플로우에 유용하게 연계할 수 있습니다.

비디오 내 텍스트 검색 - 인덱싱된 모든 비디오 내에서 특정 텍스트 검색

인덱싱된 비디오 컬렉션 내에서 적절한 텍스트 매칭 결과를 확보하기 위해 text_in_video 검색 옵션을 활용하여 검색 쿼리를 실행해 보겠습니다.

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

# Declare a dictionary named `data`
data = {
    "index_id": INDEX_ID,
    "query": "horse",
    "search_options": [
        "text_in_video"
    ]
}
               
# Make a search request
response = requests.post(SEARCH_URL, headers=default_header, json=data)
if response.status_code == 200:
    print(f"Status code: {response.status_code} - Success")
else:
    print(f"Status code: {response.status_code}")
pprint(response.json())
search_data = response.json()
</code></pre>

출력 결과:

<pre><code class="python">Status code: 200 - Success
{'data': [{'confidence': 'high',
           'end': 64,
           'metadata': [{'text': 'THE HORSE IN MOTION.',
                         'type': 'text_in_video'}],
           'score': 92.28,
           'start': 63,
           'video_id': '###a849b86daab572f349242'},
          {'confidence': 'high',
           'end': 91,
           'metadata': [{'text': 'THE HORSE IN MOTION.',
                         'type': 'text_in_video'}],
           'score': 92.28,
           'start': 88,
           'video_id': '###a849b86daab572f349242'}],
 'page_info': {'limit_per_page': 10,
               'page_expired_at': '2023-05-12T00:03:43Z',
               'total_results': 2},
 'search_pool': {'index_id': '###a73aa8b1dd6cde172a933',
                 'total_count': 3,
                 'total_duration': 5403}}
                 </code></pre>

💡 비디오 내 텍스트 검색 기능은 비디오 재생 중 화면에 시각적으로 표시되는 텍스트가 입력한 검색 쿼리와 일치하는(반드시 토씨 하나 틀리지 않고 일치할 필요는 없음) 인덱싱된 비디오 내의 모든 발생 지점을 파악하도록 설계되어 있습니다. 예를 들어, 제가 "horse moving"을 입력하면 시스템은 화면에 표시된 텍스트가 "horse in motion"인 지점을 식별해 냅니다. 단, 이때의 신뢰도(Confidence level) 평가는 "horse in motion"을 그대로 입력했을 때보다 상대적으로 낮게 나옵니다. 이 신뢰도는 저희가 입력한 자연어 쿼리와 실제 매칭되는 단어 비율에 따라 결정됩니다. 에컨대 3개 단어 중 2개 단어가 맞물렸을 경우가 오직 1개 단어만 일치했을 때보다 더 높은 신뢰도를 얻게 됩니다.

특정 검색어에 대한 Twelve Labs 플레이그라운드의 비디오 내 텍스트 검색 결과 화면 예시

입력한 쿼리에 부합하여 재생되고 있는 특정 비디오 구간

검색 쿼리가 화면상 텍스트에 부합할수록 모델의 신뢰도가 즉각 상승합니다.

결과가 웹브라우저에서 깔끔하게 보일 수 있도록 Flask 애플리케이션용 데이터를 준비하는 단계입니다.

<pre><code class="python">video_data = [{'start': d['start'], 'end': d['end'], 'confidence': d['confidence'], 'text': d['metadata'][0]['text']} for d in search_data['data']]
video_search_dict = {}

for vd in video_data:
    if search_data['data'][0]['video_id'] in video_search_dict:
        video_search_dict[search_data['data'][0]['video_id']].append(vd)
    else:
        video_search_dict[search_data['data'][0]['video_id']] = [vd]

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

출력 결과:

<pre><code class="python">
{'###a849b86daab572f349242': [{'confidence': 'high',
                               'end': 64,
                               'start': 63,
                               'text': 'THE HORSE IN MOTION.'},
                              {'confidence': 'high',
                               'end': 91,
                               'start': 88,
                               'text': 'THE HORSE IN MOTION.'}]}
</code></pre>

비디오 OCR 결과를 위해 데이터를 좀 더 정리한 다음, 준비한 모든 것을 피클(pickle) 파일로 직렬화하여 영속화하는 작업을 거칩니다.

<pre><code class="python">video_id = ocr_data.get('id')
data_list = ocr_data.get('data')

data_to_save = {
    'video_id': video_id,
    'data_list': data_list,
    'video_id_name_list': video_id_name_list,
    'video_search_dict': video_search_dict
}

import pickle

# Save data to a pickle file
with open('data.pkl', 'wb') as f:
    pickle.dump(data_to_save, f)
    </code></pre>

데모 앱 구축

이제 비디오 OCR 탐험의 마지막 단계에 도달했습니다. 모든 요소를 결합해 대화형 결과를 도출해 보겠습니다. 로컬 폴더에서 비디오를 가져오고 Jupyter 노트북에서 내보낸 피클 데이터를 로드하는 표준적인 구성 외에도, 이번에는 소수점 아래 초 단위 포맷의 타임스탬프를 직관적인 분:초 포맷으로 변환하는 추가 연산 과정이 포함됩니다. 이렇게 하면 웹페이지 상의 데이터 시각화가 훨씬 매끄럽고 명확해집니다. 아래 코드는 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__)

# Load data from a pickle file
with open('data.pkl', 'rb') as f:
    loaded_data = pickle.load(f)

# Access the data
video_id = loaded_data['video_id']
data_list = loaded_data['data_list']
video_id_name_list = loaded_data['video_id_name_list']
video_search_dict = loaded_data['video_search_dict']

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

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

@app.route('/')
def home():
    for item in data_list:
        if ":" not in str(item['start']):
            item['start'] = int(item['start'])
            item['start'] = f"{item['start'] // 60}:{item['start'] % 60:02}"
        if ":" not in str(item['end']):
            item['end'] = int(item['end'])
            item['end'] = f"{item['end'] // 60}:{item['end'] % 60:02}"


    video_id_name_dict = {video['video_id']: video['video_name'] for video in video_id_name_list}
    # video_name = video_id_name_dict.get(video_id)
    return render_template('index.html', data=data_list[:10], video_id_name_dict=video_id_name_dict, video_id=video_id, video_search_dict = video_search_dict)

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

HTML 템플릿

이제 남은 마지막 조각인 Jinja-2 기반 HTML 템플릿 코드를 작성할 시간입니다. 이 템플릿은 Flask app.py 파일을 통해 인가된 모든 데이터를 수신하여 렌더링합니다. 우리의 기본 목표는 일차적으로 비디오 OCR 추출 결과를 가독성 있게 구조화해 노출하는 것입니다. 상단의 비디오 플레이어는 전체 동영상을 커버하며, 플레이어 하단에는 비디오 내 특정 구간에서 추출된 시작 시간, 종료 시간 및 텍스트 캡션이 표 형태로 구성됩니다. 명확성을 높이기 위해 타임스탬프는 분:초 형태로 사용자에게 표시되며, 링크를 클릭하면 해당 분:초 구간으로 재생 헤더가 즉각 이동하게 됩니다. 단, JavaScript의 playVideo 타임라인 탐색 함수는 초 단위 단위 정형 입력을 요구하므로, 링크 이벤트 인자 전송 함수를 거칠 때만 다시 초 형태의 정형 상태로 환원 처리해 전달합니다.

<pre><code class="language-html"><!DOCTYPE html>
<html>
<head>
    <link rel="shortcut icon" href="#" />
    <title>Video OCR</title>
    <style>
        body {
            text-align: center;
            font-family: Arial, sans-serif;
            color: #333;
            background-color: #f5f5f5;
        }
        h1, h2 {
            color: #444;
        }
        table {
            margin: 0 auto;
            border-collapse: collapse;
            width: 80%;
            margin-top: 20px;
        }
        th, td {
            border: 1px solid #ddd;
            padding: 8px;
            text-align: center;
        }
        th {
            padding-top: 12px;
            padding-bottom: 12px;
            text-decoration: underline;
            color: black;
        }
        video {
            width: 40%;
            height: auto;
            margin-top: 20px;
        }

        /* search style */
        .video-container {
            text-align: center;
            margin-bottom: 2em;
            padding: 1em;
            background-color: #fff;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        table {
            margin: 0 auto;
            margin-bottom: 1em;
        }
        th, td {
            padding: 0.5em;
            border: 1px solid #ddd;
        }
    </style>
    <script>
        function playVideo(timeString) {
            var timeParts = timeString.split(":");
            var time = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1]);
            var video = document.querySelector('#mainVideo');
            video.currentTime = time;
            video.play();
        }
    </script>    
</head>

<body>
    <h1>Video OCR</h1>
    <h3>Video file: <i>{{ video_id_name_dict[video_id]}}</i></h3>
    <video id="mainVideo" controls>
        <source src="{{ url_for('static', filename=video_id_name_dict[video_id]|string) }}" type="video/mp4">
        Your browser does not support the video tag.
    </video>
    <br /> <br /> <br />
    <table>
        <tr>
            <th>Start</th>
            <th>End</th>
            <th>Value</th>
        </tr>
        {% for item in data %}
        <tr>
            <td><a href="javascript:void(0)" onclick="playVideo('{{ item['start'] }}')">{{ item['start'] }}</a></td>
            <td>{{ item['end'] }}</td>
            <td>{{ item['value'] }}</td>
        </tr>
        {% endfor %}
    </table>
    <br /> <br />
      
    {% for video_id, results in video_search_dict.items() %}
    <div class="video-container">
        <h1>Text-in-video Search Results</h1>  
        <h2>Video file: <i>{{ video_id_name_dict[video_id] }}</i></h2>
        <h2>Entered query: <i>{{input_query}}</i></h2>
        {% for result in results %}
        <video controls preload="metadata" style="width: 40%;">
            <source src="{{ url_for('static', filename=video_id_name_dict[video_id]) }}#t={{ result['start'] }},{{ result['end'] }}" type="video/mp4">
            Your browser does not support the video tag.
        </video>
        <table>
            <tr>
                <th>Start</th>
                <th>End</th>
                <th>Confidence</th>
                <th>Text</th>
            </tr>
            <tr>
                <td>{{ result['start'] }}</td>
                <td>{{ result['end'] }}</td>
                <td>{{ result['confidence'] }}</td>
                <td>{{ result['text'] }}</td>
            </tr>
        </table>
        {% endfor %}
    </div>
    {% endfor %}
</body>
</html>
</code></pre>

Flask 앱 실행하기

훌륭합니다! 이제 Jupyter 노트북의 마지막 셀을 실행하여 Flask 앱을 시작해 보겠습니다.

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

모든 과정이 정상적으로 설계대로 작동했음을 명확히 확인시켜 주는 다음과 유사한 출력이 표시될 것입니다 😊:

활성화된 URL 링크 주소 http://127.0.0.1:5000를 클릭하고 나면, 웹브라우저 창을 통해 다음과 같이 깔끔하게 정렬된 인터페이스 페이지가 열립니다.

여기에 본 튜토리얼을 통해 함께 작성한 전체 코드가 들어 있는 Jupyter Notebook 파일 경로가 있습니다 - https://drive.google.com/drive/folders/1D97_UU2Z0lvp3y52BHV5GKkSNOQKv3Xi?usp=share_link

맺음말

앞으로도 더욱 유익하고 알찬 모범 사례 콘텐츠들이 활발하게 업데이트될 예정입니다. 아직 참여하지 않으셨다면, 멀티모달 AI 기술에 애정을 지닌 활발한 동료 개발자들이 한데 모여 활기차게 소통하고 있는 마당인 저희 Discord 커뮤니티에 합류해 즐거운 대화를 이어가 보시길 진심으로 제안합니다.

비디오 광학 문자 인식(OCR)은 컴퓨터 비전 및 머신러닝 알고리즘을 사용하여 비디오 프레임에서 텍스트를 감지하고 추출하는 기술입니다. 비디오 OCR을 사용하면 비디오 콘텐츠를 쉽게 훑어보며 특정 단어, 문구 또는 전체 문장이 화면에 나타나는 정확한 순간을 정확히 찾아낼 수 있습니다. 콘텐츠 검색 및 탐색 간소화부터 심층적인 콘텐츠 분석, 광고 게재 최적화, 콘텐츠 요약, SEO 극대화, 그리고 규정 준수 및 모니터링에 이르기까지 그 활용 분야를 상상해 보세요.

비디오 OCR을 통해 인식할 수 있는 요소의 예는 다음과 같습니다.

  • 프레젠테이션이나 회의 중 슬라이드 내용

  • 광고, 영화, TV 프로그램 등 화면에 노출되는 제품명

  • 스포츠 중계 중 유니폼에 정식 표기된 선수나 팀 이름

  • 회의나 컨퍼런스 중 식별 가능한 명찰 및 이름

  • 강의 비디오 내 화이트보드에 적힌 낙서나 필기

  • 비디오 촬영 화면 안에 잡힌 문서

  • 화면에 표시되는 손글씨 텍스트

  • 차량 번호판 및 건물 이름

  • 영화 및 인터뷰 내의 자막, 캡션, 엔딩 크레딧

이 튜토리얼에서는 Twelve Labs 플랫폼이 두 가지 고유한 수준에서 어떻게 비디오 OCR을 가능하게 하는지 살펴보겠습니다. 비디오 수준에서는 전체 비디오를 한 번에 처리하여 그 안에 담긴 모든 텍스트 요소를 활용합니다. 반면, 인덱스 수준 방식은 초점을 좁혀 특정 키워드나 키워드 클러스터에 집중하며, 이를 자연어 쿼리로 입력하여 Twelve Labs 플랫폼에 인덱싱된 비디오 라이브러리 전체에서 포괄적인 검색을 수행합니다.

가장 큰 장점은 무엇일까요? Twelve Labs API를 사용하면 OCR 프로세스를 구현하고 유지 관리하는 복잡한 과정에 대해 걱정할 필요 없이 이 모든 것을 달성할 수 있다는 점입니다. 개발부터 인프라, 그리고 지속적인 기술 지원까지 저희가 확실히 책임집니다. 이제 준비를 마치고 비디오 OCR 영역으로 펼쳐질 이 흥미진진한 여정을 함께 시작해 보겠습니다.

준비 사항

Twelve Labs 플랫폼은 현재 오픈 베타 단계에 있으며, 가입 시 최대 10시간 분량의 무료 비디오 인덱싱 크레딧을 제공합니다. 본 튜토리얼을 본격적으로 시작하기 전에 먼저 가입하여 Twelve Labs 플랫폼의 기본 개념들을 익혀두시면 큰 도움이 됩니다. 비디오 인덱싱, 인덱싱 옵션, Task API 및 검색 옵션에 대한 이해는 튜토리얼을 매끄럽게 따라오는 데 필수적이며, 이에 대한 상세한 내용은 저의 첫 번째 튜토리얼에서 광범위하게 다루었습니다. 진행 중 막히는 부분이 있거나 길을 잃은 듯한 느낌이 든다면 언제든 편하게 문의해 주세요. 참고로, 선호하시는 플랫폼이 Discord라면 저희 Discord 서버에서의 답변 속도는 정말 빠릅니다 🚅🏎️⚡️.

튜토리얼 미리보기

이전 담론에 이어, 두 가지 고유한 관점과 수준에서 비디오 OCR 문제를 해결하는 방법을 알아봅니다. 이에 따라 본 튜토리얼을 두 개의 핵심 섹션으로 나누었으며, 마지막에는 모든 요소를 결합해 실제로 작동하는 데모 웹 앱을 만들어 피날레를 장식합니다.

비디오 OCR - 3단계 프로세스

특정 비디오에서 인식된 모든 텍스트를 추출하는 과정은 다음 세 단계로 수행됩니다.

  • 비디오 인덱싱 - 낯설지 않은 단계입니다. 이전 튜토리얼들을 잘 따라오셨다면 이 단계는 친숙한 친구처럼 느껴질 것입니다.

  • 비디오 고유 식별자 가져오기 - Twelve Labs 플랫폼이 비디오 인덱싱을 마치면 OCR을 적용하고자 하는 비디오의 고유 식별자를 가져옵니다.

  • 화면에 나타나는 텍스트 추출하기 - 생성한 특정 인덱스와 OCR 대상 비디오에 연결된 비디오 ID를 사용하여 비디오를 지정합니다. 이후 API가 번거로운 작업을 대신 수행하여 원하는 결과를 제공합니다.

비디오 내 텍스트 검색 - 인덱싱된 모든 비디오 내에서 특정 텍스트 검색

비디오 OCR을 통해 전체 비디오를 면밀히 분석하고 텍스트가 나타나는 모든 순간을 추출할 수 있었습니다. 이제 '비디오 내 텍스트 검색(text-in-video search)' 기능을 사용하면 입력하거나 검색한 텍스트가 실제로 구현되는 정확한 순간 또는 비디오 클립을 정밀하게 타겟팅할 수 있습니다. 이를 통해 방대한 비디오 카탈로그를 직접 훑어보는 데 소요되는 시간을 크게 단축할 수 있으며, 비디오 재생 중 화면에 노출되는 텍스트와 검색어 간의 일치도를 기반으로 정확한 검색 결과를 산출합니다.

첫 번째 튜토리얼에서는 자연어 쿼리와 비주얼(시청각 검색), 컨버세이션(대화 검색), 비디오 내 텍스트(OCR) 등 다양한 검색 옵션을 사용하여 인덱싱된 비디오 내의 콘텐츠 검색을 깊이 있게 다루었습니다. 이번 튜토리얼에서는 이 접근 방식을 재구성하여 오직 OCR 기술만을 활용해 비디오 내 텍스트를 검색해 보겠습니다. 처리 시간과 비용을 최적화하기 위해 오직 text_in_video 인덱싱 옵션만을 사용하여 인덱스를 생성할 것입니다. 그런 다음 text_in_video 검색 옵션으로 검색 쿼리를 실행하여 인덱싱된 비디오 내에서 관련 텍스트 일치 항목을 찾아냅니다.

데모 앱 구축

모든 결과를 종합하기 위해 API 엔드포인트에서 생성된 데이터를 웹페이지에 표시하고, 심플한 HTML 페이지를 제공하는 Flask 기반의 데모 앱을 빠르게 구동해 보겠습니다. 비디오 OCR 결과는 타임스탬프와 관련 텍스트가 표 형태로 깔끔하게 정리되어 표시되며, 텍스트 검색 영역에는 사용한 쿼리와 이에 대응하여 찾아낸 관련 비디오 세그먼트가 표시됩니다.

비디오 OCR - 3단계 프로세스

이해를 돕기 위해 기존 계정을 사용하여 인덱스에 단 두 개의 비디오만 업로드해 두었습니다. 가입은 언제든 환영합니다. 현재 오픈 베타 단계이므로 최대 10시간 분량의 비디오 콘텐츠를 인덱싱할 수 있는 무료 크레딧을 받으실 수 있습니다. 그 이상의 지원이 필요하시다면 요금제 페이지를 확인하시고 디벨로퍼(Developer) 플랜으로 업그레이드해 보세요.

비디오 인덱싱

여기서는 Jupyter 노트북에 포함해야 할 에센셜 요소들을 깊이 있게 다룹니다. 필요한 라이브러리 임포트, API URL 정의, 인덱스 생성, 인덱싱 프로세스를 시작하기 위해 로컬 파일 시스템에서 비디오를 업로드하는 과정 등이 포함됩니다.

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

!pip install requests

import os
import requests
import glob
from pprint import pprint

# Retrieve the URL of the API and my 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 = create_index(index_name = "extract_text", index_options=["text_in_video"], engine = "marengo2.5")

# Print the created index IDs
print(f"Created index IDs: {INDEX_ID}")
  </code></pre>

방금 생성한 인덱스에 두 개의 비디오를 업로드합니다. 비디오 제목은 'A Brief History of Film'(Film Thought Project 제공, 주소: https://www.youtube.com/watch?v=utntGgcsZWI)과 'GPT - Explained!'(CodeEmporium 제공, 주소: https://www.youtube.com/watch?v=3IweGfgytgY)입니다. 필자는 이 비디오들을 각각의 YouTube 채널에서 다운로드하여 로컬 하드 드라이브의 'static' 폴더에 저장했습니다. 이 로컬 파일들을 사용하여 Twelve Labs 플랫폼에 비디오를 인덱싱해 보겠습니다.

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

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

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>

비디오 고유 식별자 가져오기

이제 인덱스 내의 모든 비디오를 열거해 보겠습니다. 이를 통해 특정 비디오의 ID를 보유하고 그 안에 삽입된 모든 텍스트를 추출하는 것을 목표로 합니다. 나아가, 이전 튜토리얼의 방식과 유사하게 Flask 애플리케이션에 전달할 목적으로 비디오 ID와 각각의 타이틀 목록을 구성하고 있습니다.

<pre><code class="python"># List all the videos in an index
default_header = {
    "x-api-key": API_KEY
}
INDEX_ID='644a73aa8b1dd6cde172a933'
INDEXES_VIDEOS_URL = f"{API_URL}/indexes/{INDEX_ID}/videos"
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': '###a917186daab572f349243',
           'created_at': '2023-04-27T14:18:48Z',
           'metadata': {'duration': 1300.173875,
                        'engine_id': 'marengo2.5',
                        'filename': 'A Brief History of Film.mp4',
                        'fps': 23.976023976023978,
                        'height': 720,
                        'size': 188214297,
                        'width': 1280},
           'updated_at': '2023-04-27T14:20:11Z'},
          {'_id': '###3da86daab572f349241',
           'created_at': '2023-04-27T13:08:19Z',
           'metadata': {'duration': 550.7,
                        'engine_id': 'marengo2.5',
                        'filename': 'GPT - Explained!.mp4',
                        'fps': 30,
                        'height': 720,
                        'size': 22838593,
                        'width': 1152},
           'updated_at': '2023-04-27T13:08:42Z'}],
 'page_info': {'limit_per_page': 10,
               'page': 1,
               'total_duration': 5402.873875,
               'total_page': 1,
               'total_results': 3}}

[{'video_id': '###a849b86daab572f349242',
  'video_name': 'A Brief History of Film.mp4'},
 {'video_id': '###a73da86daab572f349241', 'video_name': 'GPT - Explained!.mp4'}]
   </code></pre>

화면에 나타나는 텍스트 추출하기

설계한 계획을 실행에 옮길 시간입니다! 이제 선택한 비디오에서 모든 텍스트 콘텐츠를 추출해 보겠습니다.

<pre><code class="python">VIDEO_ID = '###a849b86daab572f349242'
TEXT_IN_VIDEO_URL = f"{API_URL}/indexes/{INDEX_ID}/videos/{VIDEO_ID}/text-in-video"

response = requests.get(TEXT_IN_VIDEO_URL, headers=default_header)
print (f"Status code: {response.status_code}")
ocr_data = response.json()
pprint (ocr_data)
  </code></pre>

출력 결과:

<pre><code class="python">Status code: 200
{'data': [{'end': 3, 'start': 1, 'value': 'Film Thought Project'},
          {'end': 6, 'start': 5, 'value': 'Film'},
          {'end': 22,
           'start': 18,
           'value': "'L'arrivée d'un train en gare de La Ciotat"},
          {'end': 28, 'start': 18, 'value': 'Year:'},
          {'end': 28, 'start': 23, 'value': '2015'},
          {'end': 28, 'start': 23, 'value': 'Production Co.'},
          {'end': 28, 'start': 23, 'value': 'Alejandro G. Iñárritu'},
          {'end': 28, 'start': 23, 'value': 'Regency Enterprises'},
          {'end': 28, 'start': 23, 'value': "'The Revenant'"},
          {'end': 30, 'start': 29, 'value': "Let's"},
          {'end': 40, 'start': 32, 'value': 'Film:'},
          {'end': 34, 'start': 33, 'value': 'Film Thought Project'},
          {'end': 40, 'start': 35, 'value': 'Director:'},
          {'end': 40, 'start': 35, 'value': 'Production Co.'},
          {'end': 40, 'start': 36, 'value': 'Alfred Hitchcock'},
          {'end': 40, 'start': 36, 'value': '1958'},
          {'end': 40, 'start': 36, 'value': 'Alfred J. Hitchcock Productions'},
          {'end': 40, 'start': 37, 'value': 'Year:'},
          {'end': 40, 'start': 38, 'value': "'Vertigo'"},
          {'end': 45, 'start': 44, 'value': 'PRESS START'},
          {'end': 46, 'start': 45, 'value': '2020'},
          {'end': 47, 'start': 46, 'value': '2018'},
          {'end': 48, 'start': 47, 'value': '1975'},
          {'end': 53, 'start': 49, 'value': '1870s'},
          {'end': 61, 'start': 67, 'value': 'Eadweard Muybridge'},
          {'end': 69, 'start': 75, 'value': 'See you soon'}],
 'id': '###a849b86daab572f349242',
 'index_id': '###a73aa8b1dd6cde172a933'}
 </code></pre>

보시다시피 API는 놀라울 정도로 깔끔하게 화면상의 모든 텍스트를 라인별로 정환하게 추출해냈습니다. 이 텍스트들을 메타데이터로 저장해 두면 향후 콘텐츠 분류, 필터링, 정밀 검색 등 다양한 하위 워크플로우에 유용하게 연계할 수 있습니다.

비디오 내 텍스트 검색 - 인덱싱된 모든 비디오 내에서 특정 텍스트 검색

인덱싱된 비디오 컬렉션 내에서 적절한 텍스트 매칭 결과를 확보하기 위해 text_in_video 검색 옵션을 활용하여 검색 쿼리를 실행해 보겠습니다.

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

# Declare a dictionary named `data`
data = {
    "index_id": INDEX_ID,
    "query": "horse",
    "search_options": [
        "text_in_video"
    ]
}
               
# Make a search request
response = requests.post(SEARCH_URL, headers=default_header, json=data)
if response.status_code == 200:
    print(f"Status code: {response.status_code} - Success")
else:
    print(f"Status code: {response.status_code}")
pprint(response.json())
search_data = response.json()
</code></pre>

출력 결과:

<pre><code class="python">Status code: 200 - Success
{'data': [{'confidence': 'high',
           'end': 64,
           'metadata': [{'text': 'THE HORSE IN MOTION.',
                         'type': 'text_in_video'}],
           'score': 92.28,
           'start': 63,
           'video_id': '###a849b86daab572f349242'},
          {'confidence': 'high',
           'end': 91,
           'metadata': [{'text': 'THE HORSE IN MOTION.',
                         'type': 'text_in_video'}],
           'score': 92.28,
           'start': 88,
           'video_id': '###a849b86daab572f349242'}],
 'page_info': {'limit_per_page': 10,
               'page_expired_at': '2023-05-12T00:03:43Z',
               'total_results': 2},
 'search_pool': {'index_id': '###a73aa8b1dd6cde172a933',
                 'total_count': 3,
                 'total_duration': 5403}}
                 </code></pre>

💡 비디오 내 텍스트 검색 기능은 비디오 재생 중 화면에 시각적으로 표시되는 텍스트가 입력한 검색 쿼리와 일치하는(반드시 토씨 하나 틀리지 않고 일치할 필요는 없음) 인덱싱된 비디오 내의 모든 발생 지점을 파악하도록 설계되어 있습니다. 예를 들어, 제가 "horse moving"을 입력하면 시스템은 화면에 표시된 텍스트가 "horse in motion"인 지점을 식별해 냅니다. 단, 이때의 신뢰도(Confidence level) 평가는 "horse in motion"을 그대로 입력했을 때보다 상대적으로 낮게 나옵니다. 이 신뢰도는 저희가 입력한 자연어 쿼리와 실제 매칭되는 단어 비율에 따라 결정됩니다. 에컨대 3개 단어 중 2개 단어가 맞물렸을 경우가 오직 1개 단어만 일치했을 때보다 더 높은 신뢰도를 얻게 됩니다.

특정 검색어에 대한 Twelve Labs 플레이그라운드의 비디오 내 텍스트 검색 결과 화면 예시

입력한 쿼리에 부합하여 재생되고 있는 특정 비디오 구간

검색 쿼리가 화면상 텍스트에 부합할수록 모델의 신뢰도가 즉각 상승합니다.

결과가 웹브라우저에서 깔끔하게 보일 수 있도록 Flask 애플리케이션용 데이터를 준비하는 단계입니다.

<pre><code class="python">video_data = [{'start': d['start'], 'end': d['end'], 'confidence': d['confidence'], 'text': d['metadata'][0]['text']} for d in search_data['data']]
video_search_dict = {}

for vd in video_data:
    if search_data['data'][0]['video_id'] in video_search_dict:
        video_search_dict[search_data['data'][0]['video_id']].append(vd)
    else:
        video_search_dict[search_data['data'][0]['video_id']] = [vd]

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

출력 결과:

<pre><code class="python">
{'###a849b86daab572f349242': [{'confidence': 'high',
                               'end': 64,
                               'start': 63,
                               'text': 'THE HORSE IN MOTION.'},
                              {'confidence': 'high',
                               'end': 91,
                               'start': 88,
                               'text': 'THE HORSE IN MOTION.'}]}
</code></pre>

비디오 OCR 결과를 위해 데이터를 좀 더 정리한 다음, 준비한 모든 것을 피클(pickle) 파일로 직렬화하여 영속화하는 작업을 거칩니다.

<pre><code class="python">video_id = ocr_data.get('id')
data_list = ocr_data.get('data')

data_to_save = {
    'video_id': video_id,
    'data_list': data_list,
    'video_id_name_list': video_id_name_list,
    'video_search_dict': video_search_dict
}

import pickle

# Save data to a pickle file
with open('data.pkl', 'wb') as f:
    pickle.dump(data_to_save, f)
    </code></pre>

데모 앱 구축

이제 비디오 OCR 탐험의 마지막 단계에 도달했습니다. 모든 요소를 결합해 대화형 결과를 도출해 보겠습니다. 로컬 폴더에서 비디오를 가져오고 Jupyter 노트북에서 내보낸 피클 데이터를 로드하는 표준적인 구성 외에도, 이번에는 소수점 아래 초 단위 포맷의 타임스탬프를 직관적인 분:초 포맷으로 변환하는 추가 연산 과정이 포함됩니다. 이렇게 하면 웹페이지 상의 데이터 시각화가 훨씬 매끄럽고 명확해집니다. 아래 코드는 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__)

# Load data from a pickle file
with open('data.pkl', 'rb') as f:
    loaded_data = pickle.load(f)

# Access the data
video_id = loaded_data['video_id']
data_list = loaded_data['data_list']
video_id_name_list = loaded_data['video_id_name_list']
video_search_dict = loaded_data['video_search_dict']

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

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

@app.route('/')
def home():
    for item in data_list:
        if ":" not in str(item['start']):
            item['start'] = int(item['start'])
            item['start'] = f"{item['start'] // 60}:{item['start'] % 60:02}"
        if ":" not in str(item['end']):
            item['end'] = int(item['end'])
            item['end'] = f"{item['end'] // 60}:{item['end'] % 60:02}"


    video_id_name_dict = {video['video_id']: video['video_name'] for video in video_id_name_list}
    # video_name = video_id_name_dict.get(video_id)
    return render_template('index.html', data=data_list[:10], video_id_name_dict=video_id_name_dict, video_id=video_id, video_search_dict = video_search_dict)

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

HTML 템플릿

이제 남은 마지막 조각인 Jinja-2 기반 HTML 템플릿 코드를 작성할 시간입니다. 이 템플릿은 Flask app.py 파일을 통해 인가된 모든 데이터를 수신하여 렌더링합니다. 우리의 기본 목표는 일차적으로 비디오 OCR 추출 결과를 가독성 있게 구조화해 노출하는 것입니다. 상단의 비디오 플레이어는 전체 동영상을 커버하며, 플레이어 하단에는 비디오 내 특정 구간에서 추출된 시작 시간, 종료 시간 및 텍스트 캡션이 표 형태로 구성됩니다. 명확성을 높이기 위해 타임스탬프는 분:초 형태로 사용자에게 표시되며, 링크를 클릭하면 해당 분:초 구간으로 재생 헤더가 즉각 이동하게 됩니다. 단, JavaScript의 playVideo 타임라인 탐색 함수는 초 단위 단위 정형 입력을 요구하므로, 링크 이벤트 인자 전송 함수를 거칠 때만 다시 초 형태의 정형 상태로 환원 처리해 전달합니다.

<pre><code class="language-html"><!DOCTYPE html>
<html>
<head>
    <link rel="shortcut icon" href="#" />
    <title>Video OCR</title>
    <style>
        body {
            text-align: center;
            font-family: Arial, sans-serif;
            color: #333;
            background-color: #f5f5f5;
        }
        h1, h2 {
            color: #444;
        }
        table {
            margin: 0 auto;
            border-collapse: collapse;
            width: 80%;
            margin-top: 20px;
        }
        th, td {
            border: 1px solid #ddd;
            padding: 8px;
            text-align: center;
        }
        th {
            padding-top: 12px;
            padding-bottom: 12px;
            text-decoration: underline;
            color: black;
        }
        video {
            width: 40%;
            height: auto;
            margin-top: 20px;
        }

        /* search style */
        .video-container {
            text-align: center;
            margin-bottom: 2em;
            padding: 1em;
            background-color: #fff;
            border: 1px solid #ddd;
            border-radius: 4px;
            box-shadow: 0 2px 4px rgba(0,0,0,0.1);
        }
        table {
            margin: 0 auto;
            margin-bottom: 1em;
        }
        th, td {
            padding: 0.5em;
            border: 1px solid #ddd;
        }
    </style>
    <script>
        function playVideo(timeString) {
            var timeParts = timeString.split(":");
            var time = parseInt(timeParts[0]) * 60 + parseInt(timeParts[1]);
            var video = document.querySelector('#mainVideo');
            video.currentTime = time;
            video.play();
        }
    </script>    
</head>

<body>
    <h1>Video OCR</h1>
    <h3>Video file: <i>{{ video_id_name_dict[video_id]}}</i></h3>
    <video id="mainVideo" controls>
        <source src="{{ url_for('static', filename=video_id_name_dict[video_id]|string) }}" type="video/mp4">
        Your browser does not support the video tag.
    </video>
    <br /> <br /> <br />
    <table>
        <tr>
            <th>Start</th>
            <th>End</th>
            <th>Value</th>
        </tr>
        {% for item in data %}
        <tr>
            <td><a href="javascript:void(0)" onclick="playVideo('{{ item['start'] }}')">{{ item['start'] }}</a></td>
            <td>{{ item['end'] }}</td>
            <td>{{ item['value'] }}</td>
        </tr>
        {% endfor %}
    </table>
    <br /> <br />
      
    {% for video_id, results in video_search_dict.items() %}
    <div class="video-container">
        <h1>Text-in-video Search Results</h1>  
        <h2>Video file: <i>{{ video_id_name_dict[video_id] }}</i></h2>
        <h2>Entered query: <i>{{input_query}}</i></h2>
        {% for result in results %}
        <video controls preload="metadata" style="width: 40%;">
            <source src="{{ url_for('static', filename=video_id_name_dict[video_id]) }}#t={{ result['start'] }},{{ result['end'] }}" type="video/mp4">
            Your browser does not support the video tag.
        </video>
        <table>
            <tr>
                <th>Start</th>
                <th>End</th>
                <th>Confidence</th>
                <th>Text</th>
            </tr>
            <tr>
                <td>{{ result['start'] }}</td>
                <td>{{ result['end'] }}</td>
                <td>{{ result['confidence'] }}</td>
                <td>{{ result['text'] }}</td>
            </tr>
        </table>
        {% endfor %}
    </div>
    {% endfor %}
</body>
</html>
</code></pre>

Flask 앱 실행하기

훌륭합니다! 이제 Jupyter 노트북의 마지막 셀을 실행하여 Flask 앱을 시작해 보겠습니다.

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

모든 과정이 정상적으로 설계대로 작동했음을 명확히 확인시켜 주는 다음과 유사한 출력이 표시될 것입니다 😊:

활성화된 URL 링크 주소 http://127.0.0.1:5000를 클릭하고 나면, 웹브라우저 창을 통해 다음과 같이 깔끔하게 정렬된 인터페이스 페이지가 열립니다.

여기에 본 튜토리얼을 통해 함께 작성한 전체 코드가 들어 있는 Jupyter Notebook 파일 경로가 있습니다 - https://drive.google.com/drive/folders/1D97_UU2Z0lvp3y52BHV5GKkSNOQKv3Xi?usp=share_link

맺음말

앞으로도 더욱 유익하고 알찬 모범 사례 콘텐츠들이 활발하게 업데이트될 예정입니다. 아직 참여하지 않으셨다면, 멀티모달 AI 기술에 애정을 지닌 활발한 동료 개발자들이 한데 모여 활기차게 소통하고 있는 마당인 저희 Discord 커뮤니티에 합류해 즐거운 대화를 이어가 보시길 진심으로 제안합니다.