チュートリアル
Twelve Labs APIを使用してビデオOCRを実行する方法

アンキット・カレ
Twelve Labs APIを使用すると、OCRインフラを構築・保守することなく、動画からの画面上テキストの抽出や、インデックス化された動画ライブラリ全体でのテキストベースの検索が可能になります。このチュートリアルでは、動画レベルのテキスト抽出とインデックスレベルの動画内テキスト検索の両方をカバーし、結果を表示するためのFlaskアプリも紹介します。
Twelve Labs APIを使用すると、OCRインフラを構築・保守することなく、動画からの画面上テキストの抽出や、インデックス化された動画ライブラリ全体でのテキストベースの検索が可能になります。このチュートリアルでは、動画レベルのテキスト抽出とインデックスレベルの動画内テキスト検索の両方をカバーし、結果を表示するためのFlaskアプリも紹介します。

この記事の内容
ニュースレターに登録する
ニュースレターに登録する
ビデオ理解に関する最新の技術進歩、チュートリアル、業界の動向をお届けします
ビデオ理解に関する最新の技術進歩、チュートリアル、業界の動向をお届けします
AIを活用してビデオを検索、分析、探索します。
2023/05/19
13分
記事へのリンクをコピー
ビデオ光学文字認識(OCR)は、コンピュータビジョンと機械学習アルゴリズムを使用してビデオフレームからテキストを検出および抽出する技術です。ビデオOCRを使用すると、ビデオコンテンツを簡単に精査し、特定の単語、フレーズ、さらには文全体が画面に表示される正確な瞬間をピンポイントで特定できます。コンテンツ検索やナビゲーションの合理化から、コンテンツ分析の深化、広告配置の最適化、コンテンツの要約、SEOの強化、コンプライアンスとモニタリングの確保にいたるまで、その用途を想像してみてください。
ビデオOCRで認識できる要素の例は以下の通りです:
プレゼンテーションや会議中のスライドコンテンツ
広告、映画、テレビ番組などで画面上に紹介される製品名
スポーツ中継中にユニフォームに表示される選手名やチーム名
会議やカンファレンス中に表示される名札や名前
講義ビデオ内のホワイトボードへの落書き
ビデオ映像内に写り込んでいる文書
画面上に表示される手書きテキスト
ナンバープレートの番号や建物名
映画やインタビュー内の字幕、キャプション、エンディングクレジット
このチュートリアルでは、Twelve Labsプラットフォームが2つの異なるレベルでビデオOCRをどのように実現しているかを探ります。ビデオレベルでは、ビデオ全体を一度に処理し、そこに含まれるあらゆるテキスト情報を活用します。一方、インデックスレベルのアプローチでは焦点を絞り込み、特定のキーワードまたはキーワードのクラスターに狙いを定めます。これらは自然言語クエリとして入力され、Twelve Labsプラットフォーム上でインデックス化されたビデオライブラリ全体から包括的な検索を行うために使用されます。
さらに嬉しいことに、Twelve Labs APIを利用すれば、OCRプロセスの実装や維持といった細かな作業を心配することなく、これらすべてを達成できます。開発からインフラストラクチャ、さらには継続的なサポートに至るまで、私たちがお客様をバックアップします。それでは準備を整えて、ビデオOCRの領域へのエキサイティングな探検へと一緒に出発しましょう。
前提条件
Twelve Labsプラットフォームは現在オープンベータ段階にあり、新規登録時に最大10時間分の無料ビデオインデックス作成クレジットを提供しています。このチュートリアルに進む前に、登録を済ませてTwelve Labsプラットフォームの基本的な側面に慣れておくと有利です。ビデオインデックス作成、インデックスオプション、Task API、検索オプションなどの理解は、このチュートリアルをスムーズに進める上で不可欠です。これらについては、私の最初のチュートリアルで詳しく説明しています。ただし、途中で行き詰まったり迷ったりした場合は、遠慮なくお問い合わせください。ちなみに、Discordがお好みのプラットフォームであれば、私たちのDiscordサーバーでの応答時間は驚くほど速い 🚅🏎️⚡️ です。
チュートリアルの簡単な流れ
前回の説明に続き、ビデオOCRに2つの異なる角度とレベルから取り組みます。したがって、このチュートリアルを2つの極めて重要なセクションに分け、最後にすべてを統合して実際に動作するデモWebアプリを作成するフィナーレへと進みます:
ビデオOCR - 3つのステッププロセス
特定のビデオから認識されたすべてのテキストを抽出するプロセスは、次の3つのステップで構成されます:
ビデオインデックス作成 - このステップに驚きはありません。過去のチュートリアルを読んできた方にとって、このステップは馴染みのある友人のように感じられるはずです。
ビデオの一意識別子の取得 - Twelve Labsプラットフォームがビデオのインデックス作成を完了したら、OCRが必要なビデオの一意の識別子を取得します。
画面に表示されるテキストの抽出 - 作成した特定のインデックスと、OCRが必要なビデオに関連付けられたビデオIDを使用して、ビデオをピンポイントで特定します。APIが面倒な処理をすべて行い、求めている結果を提供します。
ビデオ内テキスト検索 - インデックスされたすべてのビデオ内から特定のテキストを検索する
ビデオOCRにより、ビデオ全体を精査してすべてのテキストのインスタンスを抽出することができました。今度は、ビデオ内テキスト検索機能により、入力または検索されたテキストが出現する正確な瞬間やビデオのスニペットに焦点を当てることができます。これにより、大量のビデオカタログを調査する時間が大幅に短縮され、ビデオ再生中に画面に表示されるテキストと検索用語の一致に基づいた正確な検索結果が得られます。
最初のチュートリアルでは、自然言語クエリと、ビジュアル(オーディオビジュアル検索)、会話(対話検索)、ビデオ内テキスト(OCR)などのさまざまな検索オプションを使用して、インデックス化されたビデオ内のコンテンツ検索について掘り下げました。このチュートリアルでは、アプローチを再利用し、OCR技術のみを活用してビデオ内のテキストを検索します。処理時間とコストを最適化するために、text_in_videoインデックスオプションのみを使用してインデックスを作成します。その後、text_in_video検索オプションを使用して検索クエリを実行し、インデックス化されたビデオ内で関連するテキストの一致を検出します。
デモアプリの構築
これらをすべてまとめるために、APIエンドポイントから得られたデータをWebページに表示し、シンプルなHTMLページを提供するFlaskベースのデモアプリを立ち上げます。ビデオOCRの結果は、タイムスタンプと関連テキストを表示してきれいに表形式でまとめられ、テキスト検索では使用したクエリとそれに対応して見つかったビデオセグメントが表示されます。
ビデオOCR - 3つのステッププロセス
分かりやすくするために、既存のアカウントを使用して2つのビデオのみをインデックスにアップロードしました。お気軽に新規登録してください。現在オープンベータ期間中のため、最大10時間のビデオコンテンツをインデックス化できる無料クレジットを差し上げます。それ以上の要件がある場合は、デベロッパープランへのアップグレードについて料金ページをご確認ください。
ビデオインデックス作成
ここでは、Jupyter Notebookに含める必要のある重要な要素について掘り下げます。これには、必要なインポート、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>
先ほど作成したインデックスに2つのビデオをアップロードします。ビデオのタイトルは「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 Playground'のビデオ内テキスト検索結果

入力クエリに一致し、再生されている特定のビデオの瞬間

モデルの信頼度は、クエリが画面上のテキストと完全に一致した瞬間に向上します
結果をきれいに表示できるよう、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 Notebookから送信されたシリアライズデータ(pickle)を読み込む標準的な構成のほかに、今回はタイムスタンプを「秒のみ」の形式から「分:秒」の形式へ変換するという追加の要件があります。これにより、Webページ上でのデータビジュアライゼーションがより直感的になります。以下は 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 notebook の最後のセルを実行して、Flask アプリを起動しましょう:
<pre><code class="python">%run app.py </code></pre>
すべてが予想通りに進んだことを確認する、以下のような出力が表示されるはずです 😊:

URLリンク http://127.0.0.1:5000 をクリックすると、以下のWebページが表示されます:


このチュートリアルで作成した完全なコードが含まれる Jupyter Notebook はこちらです - https://drive.google.com/drive/folders/1D97_UU2Z0lvp3y52BHV5GKkSNOQKv3Xi?usp=share_link
アウトロ
今後もさらにスリリングなコンテンツが予定されています。まだ参加していない方は、マルチモーダルAIに熱意を抱く人々が集まる活気ある Discordコミュニティ のメンバーになることを心より歓迎いたします。
ビデオ光学文字認識(OCR)は、コンピュータビジョンと機械学習アルゴリズムを使用してビデオフレームからテキストを検出および抽出する技術です。ビデオOCRを使用すると、ビデオコンテンツを簡単に精査し、特定の単語、フレーズ、さらには文全体が画面に表示される正確な瞬間をピンポイントで特定できます。コンテンツ検索やナビゲーションの合理化から、コンテンツ分析の深化、広告配置の最適化、コンテンツの要約、SEOの強化、コンプライアンスとモニタリングの確保にいたるまで、その用途を想像してみてください。
ビデオOCRで認識できる要素の例は以下の通りです:
プレゼンテーションや会議中のスライドコンテンツ
広告、映画、テレビ番組などで画面上に紹介される製品名
スポーツ中継中にユニフォームに表示される選手名やチーム名
会議やカンファレンス中に表示される名札や名前
講義ビデオ内のホワイトボードへの落書き
ビデオ映像内に写り込んでいる文書
画面上に表示される手書きテキスト
ナンバープレートの番号や建物名
映画やインタビュー内の字幕、キャプション、エンディングクレジット
このチュートリアルでは、Twelve Labsプラットフォームが2つの異なるレベルでビデオOCRをどのように実現しているかを探ります。ビデオレベルでは、ビデオ全体を一度に処理し、そこに含まれるあらゆるテキスト情報を活用します。一方、インデックスレベルのアプローチでは焦点を絞り込み、特定のキーワードまたはキーワードのクラスターに狙いを定めます。これらは自然言語クエリとして入力され、Twelve Labsプラットフォーム上でインデックス化されたビデオライブラリ全体から包括的な検索を行うために使用されます。
さらに嬉しいことに、Twelve Labs APIを利用すれば、OCRプロセスの実装や維持といった細かな作業を心配することなく、これらすべてを達成できます。開発からインフラストラクチャ、さらには継続的なサポートに至るまで、私たちがお客様をバックアップします。それでは準備を整えて、ビデオOCRの領域へのエキサイティングな探検へと一緒に出発しましょう。
前提条件
Twelve Labsプラットフォームは現在オープンベータ段階にあり、新規登録時に最大10時間分の無料ビデオインデックス作成クレジットを提供しています。このチュートリアルに進む前に、登録を済ませてTwelve Labsプラットフォームの基本的な側面に慣れておくと有利です。ビデオインデックス作成、インデックスオプション、Task API、検索オプションなどの理解は、このチュートリアルをスムーズに進める上で不可欠です。これらについては、私の最初のチュートリアルで詳しく説明しています。ただし、途中で行き詰まったり迷ったりした場合は、遠慮なくお問い合わせください。ちなみに、Discordがお好みのプラットフォームであれば、私たちのDiscordサーバーでの応答時間は驚くほど速い 🚅🏎️⚡️ です。
チュートリアルの簡単な流れ
前回の説明に続き、ビデオOCRに2つの異なる角度とレベルから取り組みます。したがって、このチュートリアルを2つの極めて重要なセクションに分け、最後にすべてを統合して実際に動作するデモWebアプリを作成するフィナーレへと進みます:
ビデオOCR - 3つのステッププロセス
特定のビデオから認識されたすべてのテキストを抽出するプロセスは、次の3つのステップで構成されます:
ビデオインデックス作成 - このステップに驚きはありません。過去のチュートリアルを読んできた方にとって、このステップは馴染みのある友人のように感じられるはずです。
ビデオの一意識別子の取得 - Twelve Labsプラットフォームがビデオのインデックス作成を完了したら、OCRが必要なビデオの一意の識別子を取得します。
画面に表示されるテキストの抽出 - 作成した特定のインデックスと、OCRが必要なビデオに関連付けられたビデオIDを使用して、ビデオをピンポイントで特定します。APIが面倒な処理をすべて行い、求めている結果を提供します。
ビデオ内テキスト検索 - インデックスされたすべてのビデオ内から特定のテキストを検索する
ビデオOCRにより、ビデオ全体を精査してすべてのテキストのインスタンスを抽出することができました。今度は、ビデオ内テキスト検索機能により、入力または検索されたテキストが出現する正確な瞬間やビデオのスニペットに焦点を当てることができます。これにより、大量のビデオカタログを調査する時間が大幅に短縮され、ビデオ再生中に画面に表示されるテキストと検索用語の一致に基づいた正確な検索結果が得られます。
最初のチュートリアルでは、自然言語クエリと、ビジュアル(オーディオビジュアル検索)、会話(対話検索)、ビデオ内テキスト(OCR)などのさまざまな検索オプションを使用して、インデックス化されたビデオ内のコンテンツ検索について掘り下げました。このチュートリアルでは、アプローチを再利用し、OCR技術のみを活用してビデオ内のテキストを検索します。処理時間とコストを最適化するために、text_in_videoインデックスオプションのみを使用してインデックスを作成します。その後、text_in_video検索オプションを使用して検索クエリを実行し、インデックス化されたビデオ内で関連するテキストの一致を検出します。
デモアプリの構築
これらをすべてまとめるために、APIエンドポイントから得られたデータをWebページに表示し、シンプルなHTMLページを提供するFlaskベースのデモアプリを立ち上げます。ビデオOCRの結果は、タイムスタンプと関連テキストを表示してきれいに表形式でまとめられ、テキスト検索では使用したクエリとそれに対応して見つかったビデオセグメントが表示されます。
ビデオOCR - 3つのステッププロセス
分かりやすくするために、既存のアカウントを使用して2つのビデオのみをインデックスにアップロードしました。お気軽に新規登録してください。現在オープンベータ期間中のため、最大10時間のビデオコンテンツをインデックス化できる無料クレジットを差し上げます。それ以上の要件がある場合は、デベロッパープランへのアップグレードについて料金ページをご確認ください。
ビデオインデックス作成
ここでは、Jupyter Notebookに含める必要のある重要な要素について掘り下げます。これには、必要なインポート、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>
先ほど作成したインデックスに2つのビデオをアップロードします。ビデオのタイトルは「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 Playground'のビデオ内テキスト検索結果

入力クエリに一致し、再生されている特定のビデオの瞬間

モデルの信頼度は、クエリが画面上のテキストと完全に一致した瞬間に向上します
結果をきれいに表示できるよう、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 Notebookから送信されたシリアライズデータ(pickle)を読み込む標準的な構成のほかに、今回はタイムスタンプを「秒のみ」の形式から「分:秒」の形式へ変換するという追加の要件があります。これにより、Webページ上でのデータビジュアライゼーションがより直感的になります。以下は 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 notebook の最後のセルを実行して、Flask アプリを起動しましょう:
<pre><code class="python">%run app.py </code></pre>
すべてが予想通りに進んだことを確認する、以下のような出力が表示されるはずです 😊:

URLリンク http://127.0.0.1:5000 をクリックすると、以下のWebページが表示されます:


このチュートリアルで作成した完全なコードが含まれる Jupyter Notebook はこちらです - https://drive.google.com/drive/folders/1D97_UU2Z0lvp3y52BHV5GKkSNOQKv3Xi?usp=share_link
アウトロ
今後もさらにスリリングなコンテンツが予定されています。まだ参加していない方は、マルチモーダルAIに熱意を抱く人々が集まる活気ある Discordコミュニティ のメンバーになることを心より歓迎いたします。
ビデオ光学文字認識(OCR)は、コンピュータビジョンと機械学習アルゴリズムを使用してビデオフレームからテキストを検出および抽出する技術です。ビデオOCRを使用すると、ビデオコンテンツを簡単に精査し、特定の単語、フレーズ、さらには文全体が画面に表示される正確な瞬間をピンポイントで特定できます。コンテンツ検索やナビゲーションの合理化から、コンテンツ分析の深化、広告配置の最適化、コンテンツの要約、SEOの強化、コンプライアンスとモニタリングの確保にいたるまで、その用途を想像してみてください。
ビデオOCRで認識できる要素の例は以下の通りです:
プレゼンテーションや会議中のスライドコンテンツ
広告、映画、テレビ番組などで画面上に紹介される製品名
スポーツ中継中にユニフォームに表示される選手名やチーム名
会議やカンファレンス中に表示される名札や名前
講義ビデオ内のホワイトボードへの落書き
ビデオ映像内に写り込んでいる文書
画面上に表示される手書きテキスト
ナンバープレートの番号や建物名
映画やインタビュー内の字幕、キャプション、エンディングクレジット
このチュートリアルでは、Twelve Labsプラットフォームが2つの異なるレベルでビデオOCRをどのように実現しているかを探ります。ビデオレベルでは、ビデオ全体を一度に処理し、そこに含まれるあらゆるテキスト情報を活用します。一方、インデックスレベルのアプローチでは焦点を絞り込み、特定のキーワードまたはキーワードのクラスターに狙いを定めます。これらは自然言語クエリとして入力され、Twelve Labsプラットフォーム上でインデックス化されたビデオライブラリ全体から包括的な検索を行うために使用されます。
さらに嬉しいことに、Twelve Labs APIを利用すれば、OCRプロセスの実装や維持といった細かな作業を心配することなく、これらすべてを達成できます。開発からインフラストラクチャ、さらには継続的なサポートに至るまで、私たちがお客様をバックアップします。それでは準備を整えて、ビデオOCRの領域へのエキサイティングな探検へと一緒に出発しましょう。
前提条件
Twelve Labsプラットフォームは現在オープンベータ段階にあり、新規登録時に最大10時間分の無料ビデオインデックス作成クレジットを提供しています。このチュートリアルに進む前に、登録を済ませてTwelve Labsプラットフォームの基本的な側面に慣れておくと有利です。ビデオインデックス作成、インデックスオプション、Task API、検索オプションなどの理解は、このチュートリアルをスムーズに進める上で不可欠です。これらについては、私の最初のチュートリアルで詳しく説明しています。ただし、途中で行き詰まったり迷ったりした場合は、遠慮なくお問い合わせください。ちなみに、Discordがお好みのプラットフォームであれば、私たちのDiscordサーバーでの応答時間は驚くほど速い 🚅🏎️⚡️ です。
チュートリアルの簡単な流れ
前回の説明に続き、ビデオOCRに2つの異なる角度とレベルから取り組みます。したがって、このチュートリアルを2つの極めて重要なセクションに分け、最後にすべてを統合して実際に動作するデモWebアプリを作成するフィナーレへと進みます:
ビデオOCR - 3つのステッププロセス
特定のビデオから認識されたすべてのテキストを抽出するプロセスは、次の3つのステップで構成されます:
ビデオインデックス作成 - このステップに驚きはありません。過去のチュートリアルを読んできた方にとって、このステップは馴染みのある友人のように感じられるはずです。
ビデオの一意識別子の取得 - Twelve Labsプラットフォームがビデオのインデックス作成を完了したら、OCRが必要なビデオの一意の識別子を取得します。
画面に表示されるテキストの抽出 - 作成した特定のインデックスと、OCRが必要なビデオに関連付けられたビデオIDを使用して、ビデオをピンポイントで特定します。APIが面倒な処理をすべて行い、求めている結果を提供します。
ビデオ内テキスト検索 - インデックスされたすべてのビデオ内から特定のテキストを検索する
ビデオOCRにより、ビデオ全体を精査してすべてのテキストのインスタンスを抽出することができました。今度は、ビデオ内テキスト検索機能により、入力または検索されたテキストが出現する正確な瞬間やビデオのスニペットに焦点を当てることができます。これにより、大量のビデオカタログを調査する時間が大幅に短縮され、ビデオ再生中に画面に表示されるテキストと検索用語の一致に基づいた正確な検索結果が得られます。
最初のチュートリアルでは、自然言語クエリと、ビジュアル(オーディオビジュアル検索)、会話(対話検索)、ビデオ内テキスト(OCR)などのさまざまな検索オプションを使用して、インデックス化されたビデオ内のコンテンツ検索について掘り下げました。このチュートリアルでは、アプローチを再利用し、OCR技術のみを活用してビデオ内のテキストを検索します。処理時間とコストを最適化するために、text_in_videoインデックスオプションのみを使用してインデックスを作成します。その後、text_in_video検索オプションを使用して検索クエリを実行し、インデックス化されたビデオ内で関連するテキストの一致を検出します。
デモアプリの構築
これらをすべてまとめるために、APIエンドポイントから得られたデータをWebページに表示し、シンプルなHTMLページを提供するFlaskベースのデモアプリを立ち上げます。ビデオOCRの結果は、タイムスタンプと関連テキストを表示してきれいに表形式でまとめられ、テキスト検索では使用したクエリとそれに対応して見つかったビデオセグメントが表示されます。
ビデオOCR - 3つのステッププロセス
分かりやすくするために、既存のアカウントを使用して2つのビデオのみをインデックスにアップロードしました。お気軽に新規登録してください。現在オープンベータ期間中のため、最大10時間のビデオコンテンツをインデックス化できる無料クレジットを差し上げます。それ以上の要件がある場合は、デベロッパープランへのアップグレードについて料金ページをご確認ください。
ビデオインデックス作成
ここでは、Jupyter Notebookに含める必要のある重要な要素について掘り下げます。これには、必要なインポート、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>
先ほど作成したインデックスに2つのビデオをアップロードします。ビデオのタイトルは「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 Playground'のビデオ内テキスト検索結果

入力クエリに一致し、再生されている特定のビデオの瞬間

モデルの信頼度は、クエリが画面上のテキストと完全に一致した瞬間に向上します
結果をきれいに表示できるよう、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 Notebookから送信されたシリアライズデータ(pickle)を読み込む標準的な構成のほかに、今回はタイムスタンプを「秒のみ」の形式から「分:秒」の形式へ変換するという追加の要件があります。これにより、Webページ上でのデータビジュアライゼーションがより直感的になります。以下は 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 notebook の最後のセルを実行して、Flask アプリを起動しましょう:
<pre><code class="python">%run app.py </code></pre>
すべてが予想通りに進んだことを確認する、以下のような出力が表示されるはずです 😊:

URLリンク http://127.0.0.1:5000 をクリックすると、以下のWebページが表示されます:


このチュートリアルで作成した完全なコードが含まれる Jupyter Notebook はこちらです - https://drive.google.com/drive/folders/1D97_UU2Z0lvp3y52BHV5GKkSNOQKv3Xi?usp=share_link
アウトロ
今後もさらにスリリングなコンテンツが予定されています。まだ参加していない方は、マルチモーダルAIに熱意を抱く人々が集まる活気ある Discordコミュニティ のメンバーになることを心より歓迎いたします。




