チュートリアル

ビデオ内を正確に検索:Twelve Labs APIを組み合わせたクエリ検索

アンキット・カレ

開発者はTwelve Labs Search APIを使用し、AND、OR、NOT、THENなどの演算子を用いて複数の自然言語クエリを組み合わせることで、複雑な条件に一致する正確な動画の瞬間をピンポイントで特定できます。例えば、特定の自動車ブランドが画面に表示されていると同時にドリフトしているシーンを見つけることなどが可能です。このチュートリアルでは、2つのインデックスにまたがる複合クエリについて説明し、その結果をFlaskアプリに表示する方法を紹介します。

開発者はTwelve Labs Search APIを使用し、AND、OR、NOT、THENなどの演算子を用いて複数の自然言語クエリを組み合わせることで、複雑な条件に一致する正確な動画の瞬間をピンポイントで特定できます。例えば、特定の自動車ブランドが画面に表示されていると同時にドリフトしているシーンを見つけることなどが可能です。このチュートリアルでは、2つのインデックスにまたがる複合クエリについて説明し、その結果をFlaskアプリに表示する方法を紹介します。

この記事の内容

No headings found on page

ニュースレターに登録する

ニュースレターに登録する

ビデオ理解に関する最新の技術進歩、チュートリアル、業界の動向をお届けします

ビデオ理解に関する最新の技術進歩、チュートリアル、業界の動向をお届けします

AIを活用してビデオを検索、分析、探索します。

2023/04/18

16分

記事へのリンクをコピー

前提

映画ファン🎬🍿でありコンテンツクリエイター🎨🖌️✍️でもある私は、お気に入りの映画コレクションを保管するために自分自身のPlexサーバーを構築しました。ストーリーテリングを引き立て、より魅力的なコンテンツを作成するために、映画のシーンを逸話として使用することがよくあります。例えば、モチベーションや意志の強さ、困難の克服をテーマにした動画を作る場合、大人気アドベンチャーアニメ『ドラゴンボール超』のエキサイティングな超サイヤ人への変身シーンや、お気に入りの映画『ネバー・バックダウン』のワークアウトやトレーニングのシーンなど、関連する瞬間を紹介するかもしれません。あるいは、新しい映画の脚本を制作中の映画監督や脚本家が、類似する映画のセットを分析して、コメディシーンの数、その長さ、ドリフトレースの発生、マッスルカーが登場する頻度といった、共通のテーマやパターンを特定したいと考える場合もあるでしょう。膨大な映画作品の中から、あるいは1本の映画の中から特定のシーンを見つけ出すことは、どれほど記憶力に優れた人にとっても非常に困難な作業です。そこで役に立つのが、ビデオ理解(Video Understanding)テクノロジーです 🛟⛑️。

Twelve LabsのSearch APIは、ユーザーがシンプルな自然言語クエリを作成し、それらを独創的に組み合わせることで、関連するビデオセグメントを洗い出すことができる、柔軟なビデオ検索ソリューションを提供します。例えば、主演俳優が赤い三菱の車を運転する、特定のドリフトシーンを検出する複合クエリを作成することができます。あるいは、お気に入りのフォーミュラ1カーが勝利のチェッカーフラッグを切る、スリリングな瞬間を検索することも可能です 🏁✌️。

インデックス化された映画『ワイルド・スピードX3 TOKYO DRIFT』から、複合クエリ「'drift'(検索オプション:visual:視覚情報)AND 'Mitsubishi'(検索オプション:logo:ロゴ)」を実行した検索結果です 😎

はじめに

このチュートリアルシリーズの第1リファレンスでは、検索リクエストで一度に1つのクエリのみを使用する「シンプル検索」を使用して、ビデオ内の検索を行う方法を探りました。このフォローアップチュートリアルを最大限に活用するために、Twelve Labs Search APIの基本を理解するために前回のチュートリアルを復習することを強くお勧めします。基本を十分に理解していることを前提として、このチュートリアルではさらに高度な概念を紹介します。Twelve Labs APIが提供する「複合クエリ(Combined Queries)」機能について深く掘り下げていきます。これにより、インデックス化されたビデオ内から関心のある特定の瞬間を柔軟かつ便利に特定できます。これを示すために、私は2つの別々のインデックスを作成します。1つはフォーミュラ1のレース用、もう1つは映画『ワイルド・スピード』シリーズでお馴染みの本編『ワイルド・スピードX3 TOKYO DRIFT』用です。次に、さまざまな演算子を使用して検索クエリを組み合わせ、探している興味深い瞬間を特定する方法を示します。それでは、チュートリアルの概要に進み、このガイド全体で学べる内容を具体的に説明していきましょう。

クイック概要

  • 前提条件: Twelve Labs APIスイートにサインアップし、必要なパッケージをインストールして、このチュートリアルをスムーズに進められるようにしましょう。最初のチュートリアルも忘れずにチェックしてください!

  • ビデオのアップロード: Twelve Labsプラットフォームにビデオを送信すると、プラットフォームがビデオを簡単にインデックス化します。これにより、複雑な複合クエリを入力して、探している瞬間をフェッチすることができます!

  • 複合クエリ: ここからが本当の醍醐味です!複合クエリとは、簡潔に定義すると、「or」、「and」、「not」、「then」などの演算子を使用して、2つ以上のシンプルな検索クエリを結合して1つの統一されたクエリにしたものです。これらの演算子の理論的な側面を簡単に確認した後、それらを使用して2つ以上の自然言語クエリを効果的に結合する実用的な例を詳しく見ていきましょう。これにより、インデックス化されたビデオの中から複合クエリに意味的に一致する決定的な瞬間を見つけることができます。

  • デモアプリの作成: 検索APIからの結果を利用し、コンピュータのローカルフォルダに保存されているビデオにアクセスして、カスタム設計されたスマートなHTMLページをレンダリングし、検索結果をスタイリッシュに表示する、Flaskベースの便利なアプリを作成します。

💡 ちなみに、この記事を読んでいるあなたが開発者でなくても心配ありません!すぐに使えるJupyter notebookへのリンクを用意しています。クエリや演算子を簡単に調整してプロセス全体を実行し、結果を取得することができます 😄。お楽しみください!

前提条件

前回のチュートリアルが、このチュートリアルの唯一の前提条件です。これらを読んでいる最中に何かつまずくことがあれば、遠慮なくサポートを求めてください!私たちのDiscordサーバーでは、非常に迅速な対応を行っています 🚅🏎️⚡️。Discordがお好みに合わない場合は、メールでお気軽にお問い合わせください。Twelve Labsアカウントを作成すると、APIダッシュボードにアクセスしてAPIキーを取得できます。このデモでは、私の既存のアカウントを使用します:

<pre><code class="bash">%env API_KEY=<your_API_key>
%env API_URL=https://api.twelvelabs.io/v1.1
</code></pre>
<pre><code class="python">!pip install requests
!pip install flask

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>

ビデオのアップロード

これは最初のステップです。ここでは、最新の最先端ビデオ理解エンジン「Marengo 2.5」を使用して2つのインデックスを作成しますが、それぞれ異なるインデックス作成オプションを適用します。フォーミュラ1のレースに焦点を当てたインデックスでは、視覚情報(visual)と音声対話(conversation)に加えて、ビデオ内テキスト(text-in-video)とロゴ(logo)オプションを有効にすることが有益です。フォーミュラ1イベントには、車両、トラック、フェンス、そして表彰式中の画面上に表示される大量のテキストなど、ロゴが豊富に含まれているためです。しかし、映画『ワイルド・スピードX3 TOKYO DRIFT』のインデックスでは、ビデオ内テキスト(text-in-video)オプションを有効にしても価値がない可能性があります。ここで、異なるオプションでインデックスを作成する柔軟性が役に立ちます。特定のニーズに合わせてインデックス作成オプションをカスタマイズすることで、計算リソースの使用を最適化し、最終的にコストを節約できます。

<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

# Specify the names of the indexes
index_names = ["formula_one", "tokyo_drift"]

# Create the indexes
index_id_formula_one = create_index(index_name = "formula_one", index_options=["visual", "conversation", "text_in_video", "logo"], engine = "marengo2.5")
index_id_tokyo_drift = create_index(index_name = "tokyo_drift", index_options=["visual", "conversation", "logo"], engine = "marengo2.5")

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

出力:

<pre><code class="language-plaintext">Status code: 201 - The request was successful and a new resource was created.
{'_id': '##38fb695b65d57eaecaf8##'}
Status code: 201 - The request was successful and a new resource was created.
{'_id': '##38fb695b65d57eaecaf8##'}
Created index IDs: ##38fb695b65d57eaecaf8##, ##38fb695b65d57eaecaf8##
</code></pre>

ビデオインデックス作成タスクの開始

特定のフォルダからすべてのビデオを自動的に取り込み、ビデオファイル自体と同じ名前を割り当て、非同期でプラットフォームにアップロードするようにコードを設定しました。インデックスに含めたいすべてのビデオを1つのフォルダに配置してください。並行アップロード用のスレッドを作成せずに非同期で「for」ループを使用してビデオをアップロードしたとしても、システムはそれらを同期(同時)にインデックス化するため、総インデックス作成時間は最も長いビデオの長さの約40%になります。後で同じインデックス内にさらに多くのビデオをインデックス化したい場合も問題ありません!新しいビデオファイル用に新しいフォルダを作成する必要はありません。既存のフォルダに追加するだけで、インデックス作成を開始する前に、同じ名前のインデックス化されたビデオがすでに存在するか、または同じ名前のビデオに対する保留中のインデックス作成タスクがあるかどうかがコードによってチェックされます。これにより重複を回避できます。素晴らしいですよね? 😄

<pre><code class="python">TASKS_URL = f"{API_URL}/tasks"
TASK_ID_LIST = []
video_folder = 'static'  # folder containing the video files
INDEX_ID = index_id_tom  # change this to the other index id while creating the index for lex fridman podcast videos
# INDEX_ID = '##38d9c4e4225d1c0eb1e8##'

# Iterate through all the video files in the folder
for file_name in os.listdir(video_folder):
    # 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")
            continue

    #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")
</code></pre>

出力:

<pre><code class="language-plaintext">Entering task creation code for the file:  20211113T190000Z.mp4
Status code: 201 - The request was successful and a new resource was created.
File name: 20211113T190000Z.mp4
{'_id': '6438fb6e5b65d57eaecaf8bc'}


Entering task creation code for the file:  20211113T193300Z.mp4
Status code: 201 - The request was successful and a new resource was created.
File name: 20211113T193300Z.mp4
{'_id': '##38fb755b65d57eaecaf8##'}
</code></pre>

インデックス作成プロセスの監視

監視機能は、現在インデックス作成中であるビデオの推定残り時間を表示するように設計しました。インデックス作成タスクが完了すると、監視プロセスは次のビデオインデックス作成タスクへと移行します。これはシステムが並行処理のアプローチをとっているため、次のタスクはすでに進行中です。フォルダ内のすべてのビデオがインデックス化されるまで、このプロセスが継続されます。最後に、この同期インデックス作成にかかった総時間が秒単位で表示されます。

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

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

# Monitor the indexing process for all tasks
for task_id in TASK_ID_LIST:
    print("Current Task being monitored: ", task_id)
    monitor_upload_status(task_id)

# Define ending time
end = time.time()
print("Uploading finished")
print("Time elapsed (in seconds): ", end - start)
</code></pre>
<pre><code class="language-plaintext">Starting to monitor...
Current Task being monitored:  ##38fb6e5b65d57eaecaf8##
........Remaining seconds: 264.48919677734375, Upload Percentage: 0
Remaining seconds: 258.351806640625, Upload Percentage: 2
Remaining seconds: 253.1555633544922, Upload Percentage: 4
Remaining seconds: 247.93516540527344, Upload Percentage: 6
Remaining seconds: 242.26431274414062, Upload Percentage: 8
Remaining seconds: 237.22894287109375, Upload Percentage: 10
Remaining seconds: 231.01914978027344, Upload Percentage: 12
Remaining seconds: 224.7932891845703, Upload Percentage: 15
Remaining seconds: 218.599609375, Upload Percentage: 17
</code></pre>

インデックス内のすべてのビデオの一覧表示

必要なすべてのビデオがインデックス化されたことを確認するために、インデックス内に存在するすべてのビデオを一覧表示してダブルチェックしましょう。さらに、すべてのビデオIDとそれに対応する名前を含むリストを作成します。これは、対応するビデオのクリップ(開始タイムスタンプと終了タイムスタンプ付きで表示される)を取得し、表示するために後で使用します。

<pre><code class="python"># List all the videos in an index
INDEX_ID='##38d9c4e4225d1c0eb1e8##'
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']]

print(video_id_name_list)
</code></pre>
<pre><code class="bash">{'data': [{'_id': '##3d978c86daab572f3481##',
           'created_at': '2023-04-17T18:56:51Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211113T190000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 415876158,
                        'width': 704},
           'updated_at': '2023-04-17T19:01:32Z'},
          {'_id': '##3d975786daab572f3481##',
           'created_at': '2023-04-17T18:56:44Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211114T170000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 387273943,
                        'width': 704},
           'updated_at': '2023-04-17T19:00:39Z'},
          {'_id': '##3d972e86daab572f3481##',
           'created_at': '2023-04-17T18:56:38Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211113T193000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 386209689,
                        'width': 704},
           'updated_at': '2023-04-17T18:59:58Z'},
          {'_id': '##3d96d386daab572f3481##',
           'created_at': '2023-04-17T18:56:28Z',
           'metadata': {'duration': 1800.52,
                        'engine_id': 'marengo2.5',
                        'filename': '20211121T133000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 348611416,
                        'width': 704},
           'updated_at': '2023-04-17T18:58:27Z'},
          {'_id': '##3d96af86daab572f3481##',
           'created_at': '2023-04-17T18:56:08Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211113T200000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 327766175,
                        'width': 704},
           'updated_at': '2023-04-17T18:57:51Z'}],
 'page_info': {'limit_per_page': 10,
               'page': 1,
               'total_duration': 9002.76,
               'total_page': 1,
               'total_results': 5}}

[{'video_id': '##3d978c86daab572f3481##', 'video_name': '20211113T190000Z.mp4'},
 {'video_id': '##3d975786daab572f3481##', 'video_name': '20211114T170000Z.mp4'},
 {'video_id': '##3d972e86daab572f3481##', 'video_name': '20211113T193000Z.mp4'},
 {'video_id': '##3d96d386daab572f3481##', 'video_name': '20211121T133000Z.mp4'},
 {'video_id': '##3d96af86daab572f3481##', 'video_name': '20211113T200000Z.mp4'}]
 </code></pre>

複合クエリ

システムがビデオのインデックス作成とビデオの分散表現(埋め込み表現、embedding)の生成を完了したら、検索APIを使用して、意味的に一致する最も優れた瞬間を見つける準備が整います。前回のチュートリアルでは、シンプルなクエリの使用方法について紹介しました。ここでは、実用的な「複合クエリ」を作成することに焦点を当てます。

検索APIを使用すると、次の演算子を使用して複合クエリを構築できます。

  • AND:この演算子は、単純クエリの積集合を表します。例えば、「赤い車(a red car)」と「青い車(a blue car)」という2つの単純クエリを「and」演算子で組み合わせると、赤い車と青い車の両方が存在するすべてのシーンがフェッチされます。

  • OR:この演算子は、単純クエリの和集合に使用されます。2つの単純クエリ「赤い車」と「青い車」を「or」演算子で組み合わせると、赤い車または青い車のいずれかが存在するすべてのシーンがフェッチされます。

  • NOT:この演算子を使用するには、キーを $not 文字列とし、値を originsub という2つのクエリで構成されたディクショナリ(辞書)にした、ディクショナリを構築する必要があります。APIは、origin クエリには一致するが、sub クエリには一致しないビデオクリップを返します。「赤い車」を originとし、「青い車」を sub とした場合、赤い車は存在するが、青い車は存在しないビデオクリップをフェッチします。なお、 originsub クエリの両方に、任意の数のサブクエリを含めることができます。

  • THEN:この演算子は、キーを $then 文字列とし、値をオブジェクトの配列(各オブジェクトがサブクエリを表す)とした、ディクショナリを構築することで使用できます。APIは、一致するビデオ断片の順序がサブクエリの順序と一致する場合にのみ結果を返します。つまり私たちが使用している例の場合、赤い車が表示され、その後に青い車が続くという決定的なシーケンスを持つビデオクリップが返されます。

理論的な部分は以上です。それでは、実際に複合クエリを使用して最初の検索を実行し、その優れた応用性能を目の当たりにしましょう。この複合クエリは、異なる検索オプションを持つ2つの単純クエリを「AND」演算子を使用して組み合わせたものです。最初のクエリは、音声と視覚の両方において「トロフィーを獲得する(winning a trophy)」という概念と意味的に類似しているシーンを探すためのものです。一方、2番目のクエリは「crypto.com」というテキストまたはロゴが含まれるシーンを探すためのものです。これらのクエリを組み合わせることで、両方の基準を同時に満たすビデオクリップを見つけることができます。

<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,
    "search_options": ["visual"],
    "query": {
                "$and": [
                    {
                        "text": "winning trophy",
                        "search_options": ["visual"]
                    },
                    {
                        "text": "crypto.com",
                        "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())
</code></pre>

出力:

<pre><code class="bash">Status code: 200 - Success
{'data': [{'confidence': 'high',
           'end': 20,
           'score': 92.28,
           'start': 18,
           'video_id': '##3d96af86daab572f3481##'},
          {'confidence': 'high',
           'end': 43,
           'score': 92.28,
           'start': 42,
           'video_id': '##3d978c86daab572f3481##'},
          {'confidence': 'high',
           'end': 71,
           'score': 92.28,
           'start': 61,
           'video_id': '##3d978c86daab572f3481##'},
          {'confidence': 'high',
           'end': 62,
           'score': 92.28,
           'start': 61,
           'video_id': '##3d96af86daab572f3481##'},
          {'confidence': 'high',
           'end': 67,
           'score': 92.28,
           'start': 65,           
           'video_id': '##3d96af86daab572f3481##'}],
 'page_info': {'limit_per_page': 10,
               'next_page_token': '##69daa3-827f-4165-982d-ec0d34f97c7c-1',
               'page_expired_at': '2023-04-17T23:56:00Z',
               'total_results': 110},
 'search_pool': {'index_id': '##3d9556f607a5a7bd9ea5##',
                 'total_count': 5,
                 'total_duration': 9003}}
</code></pre>

対応するビデオクリップ:

<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/1a08de21a4d14f85ab3ee125660438da" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>

<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/e2cb3c58f49c4b00b0c6c5b2f745acfc" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>



この部分は、知能(intelligence)の存在を浮き彫りにするため、私を非常にワクワクさせてくれます。このモデルは、ビデオコンテンツに対して人間のような理解を示します。上記のスクショ(ビデオ)でご覧いただけるように、システムが私が抽出したかったまさにその瞬間を特定することで、完璧に成功を収めました。

今度は、Tokyo Drift の本編を含む2番目のインデックスに対して、単純クエリのセットを結合して具体的に検索を行ってみましょう:

<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,
    "search_options": ["visual"],
    "query": {
                "$and": [
                    {
                        "text": "drift",
                        "search_options": ["visual"]
                    },
                    {
                        "text": "mitsubishi",
                        "search_options": ["logo"]
                    }
                ]
            }
        }
# 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())
</code></pre>

出力:

<pre><code class="bash">Status code: 200 - Success
{'data': [{'confidence': 'high',
           'end': 3710,
           'score': 92.28,
           'start': 3705,
           'video_id': '##3e3ace86daab572f3481##'}],
 'page_info': {'limit_per_page': 10,
               'page_expired_at': '2023-04-18T09:09:59Z',
               'total_results': 1},
 'search_pool': {'index_id': '##3e3647f607a5a7bd9ea5##',
                 'total_count': 1,
                 'total_duration': 6246}}
</code></pre>

対応するビデオクリップ:

<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/a7fc79ff424f4d50b7a42dc2bd134473" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>

当たりです!今回も、システムが完璧な瞬間を正確に特定しました。このシーンでは、主演俳優のショーン(ルーカス・ブラック)が、赤い三菱自動車を巧みにドリフトさせています。

各ビデオのID、対応するタイトル、およびそれぞれの開始タイムスタンプと終了タイムスタンプを含むPythonのリストを準備しましょう。次のステップでこのリストをFlaskアプリに渡し、検索結果をウェブページに表示できるようにします:

<pre><code class="python">response_data = response.json()  

# Extract unique video IDs
unique_video_ids = list(set([item['video_id'] for item in response_data['data']]))

# Create empty start and end instances lists for each video ID
video_start_end_lists = {video_id: {'starts': [], 'ends': []} for video_id in unique_video_ids}

def find_video_name(video_id, video_id_name_list):
    for video in video_id_name_list:
        if video['video_id'] == video_id:
            return video['video_name']
    return None

# Append start and end instances to their respective lists
for item in response_data['data']:
    video_id = item['video_id']
    video_start_end_lists[video_id]['starts'].append(item['start'])
    video_start_end_lists[video_id]['ends'].append(item['end'])

for video_id, timestamps in video_start_end_lists.items():
    video_name = find_video_name(video_id, video_id_name_list)
    if video_name:
        timestamps['video_name'] = video_name
    else:
        print(f"No video name found for ID '{video_id}'")

# Print the result
pprint(video_start_end_lists)
</code></pre>
<pre><code class="bash">{'##3d96af86daab572f3481##': {'ends': [20, 62, 67, 114],
                              'starts': [18, 61, 65, 111],
                              'video_name': '20211113T200000Z.mp4'},
 '643d972e86daab572f34810d': {'ends': [84],
                              'starts': [68],
                              'video_name': '20211113T193000Z.mp4'},
 '643d975786daab572f34810e': {'ends': [79],
                              'starts': [70],
                              'video_name': '20211114T170000Z.mp4'},
 '643d978c86daab572f34810f': {'ends': [43, 71, 85, 95],
                              'starts': [42, 61, 84, 91],
                              'video_name': '20211113T190000Z.mp4'}}
</code></pre>

後でFlaskアプリで使用するためにリストを保存するには、リストをファイルにシリアル化(オブジェクトの永続化、pickle化)することができます:



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

with open("lists.pkl", "wb") as f:
    pickle.dump(video_start_end_lists, f)
</code></pre>

デモアプリの作成

いよいよ最終ステップです。受信したJSONレスポンスを利用して、開始点と終了点を手動で特定することなく、ビデオクリップを効率的に取得および表示します。これを実現するために、これらのタイムスタンプを利用してローカルドライブから取得したビデオに適用できるウェブページをホストします。その結果、検索に一致する魅力的なビデオクリップがウェブページ上にシームレスに表示されます。

ディレクトリ構造は以下のようになります。

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

Flaskアプリのコード

以下は、「app.py」ファイルのコードです:

<pre><code class="python">
from flask import Flask, render_template
import pickle

with open("lists.pkl", "rb") as f:
   video_start_end_lists = pickle.load(f)

app = Flask(__name__)

@app.route("/")
def index():
    return render_template("index_local.html", video_start_end_lists=video_start_end_lists)

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

HTMLテンプレート

以下は、先ほど準備したリストを反復処理し、ローカルドライブから必要なビデオを取得し、複合クエリの結果を表示するコードをHTMLファイル内に組み込んだ、Jinja2ベースのサンプルHTMLテンプレートです:

<pre><code class="language-html"><!DOCTYPE html>
    <html>
      <head>
        <link rel="shortcut icon" href="#" />
        <style>
          body {
            background-color: #FFE0B2; /* Light Orange */
            font-family: Arial, sans-serif;
            text-align: center;
            margin: 0;
          }
          h1 {
            font-size: 3em;
            color: #000000; /* Black */
            background-color: #9ACD32; /* Light Green */
            padding: 20px;
            margin: 0;
          }
          h2 {
            font-size: 2em;
            color: #000000; /* Black */
            margin-bottom: 20px;
            text-align: left;
            padding-left: 20px;
          }
          .video-container {
            display: flex;
            flex-wrap: wrap;
            padding: 2px;
            justify-content: space-evenly;
            gap: 2px;
          }
          .video-item {
            display: flex;
            flex-direction: column;
            align-items: center;
            width: 45%;
            height: 450px;
            margin: 20px;
            text-align: center;
            background-color: #FFFFFF;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
            border-radius: 10px;
            padding: 20px;
          }
          .video-item video {
            width: 100%;
            height: 380px;
            margin: 0; /* Remove bottom margin */
            border-radius: 5px;
          }
          .video-item p {
            font-size: 16px;
            margin-top: 10px;
            font-weight: bold;
            color: #212121; /* Dark Grey */
          }
          .video-item span {
            color: #9ACD32; /* Light Green */
          }
        </style>
      </head>
      <body>
        <h1>My Favorite Scenes</h1>
        {% for video_id, segments in video_start_end_lists.items() %}
        <div class="video-section">
          <h2>Scenes from {{segments['video_name']}}</h2>
          <div class="video-container">
            {% for i in range(segments['starts']|length) %}
              <div class="video-item">
                <video id="video_{{ video_id }}_{{ i }}" width="560" height="315" controls>
                  <source src="{{ url_for('static', filename= segments['video_name']) }}" type="video/mp4">
                  Your browser does not support the video tag.
                </video>
                <p>Start: <span>{{ segments['starts'][i] }}</span> | End: <span>{{ segments['ends'][i] }}</span></p>
                <script>
                  document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("loadedmetadata", function() {
                    this.currentTime = {{ segments['starts'][i] }};
                  });
                  document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("timeupdate", function() {
                    if (this.currentTime >= {{ segments['ends'][i] }}) {
                      this.pause();
                    }
                  });
                </script>
              </div>
            {% endfor %}
          </div>
        </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 をクリックすると、複合検索クエリに応じて、以下のように出力が表示されます:

このチュートリアルを通じて構築した完全なコードを含むJupyter Notebookはこちらからアクセスできます - https://tinyurl.com/combinedQueries

試してみると面白いアクティビティ

  1. 検索オプションと演算子のさまざまな順列や組み合わせを試してみて、Discordチャンネルに参加している他のマルチモーダル愛好家と分析結果を共有しましょう。

  2. 単純クエリの表現バリエーションを試して、結果が一貫しているか、あるいは異なるかを確認します。クエリを組み合わせる際、クエリ内の言語的なニュアンスが、検索結果や一致するビデオクリップの精度にどのように影響するかを探ってみましょう。

  3. コードを拡張して、開発者としての実力を発揮しましょう!すべてのビデオを一度に並行してアップロードするメカニズムを構築し、それに応じてインデックス作成プロセスを監視するようにコードを修正します。

次回の予告

次回の投稿では、Classification API(分類API)を詳しく掘り下げ、リアルタイムで分類基準を作成してビデオセットを効果的に分類する方法を構築します。次回の面白いコンテンツも楽しみにお待ちください。マルチモーダル基盤モデルに情熱を持つ同じ考えを持つ人々と繋がるために、私たちのDiscordコミュニティへの参加もお忘れなく。

前提

映画ファン🎬🍿でありコンテンツクリエイター🎨🖌️✍️でもある私は、お気に入りの映画コレクションを保管するために自分自身のPlexサーバーを構築しました。ストーリーテリングを引き立て、より魅力的なコンテンツを作成するために、映画のシーンを逸話として使用することがよくあります。例えば、モチベーションや意志の強さ、困難の克服をテーマにした動画を作る場合、大人気アドベンチャーアニメ『ドラゴンボール超』のエキサイティングな超サイヤ人への変身シーンや、お気に入りの映画『ネバー・バックダウン』のワークアウトやトレーニングのシーンなど、関連する瞬間を紹介するかもしれません。あるいは、新しい映画の脚本を制作中の映画監督や脚本家が、類似する映画のセットを分析して、コメディシーンの数、その長さ、ドリフトレースの発生、マッスルカーが登場する頻度といった、共通のテーマやパターンを特定したいと考える場合もあるでしょう。膨大な映画作品の中から、あるいは1本の映画の中から特定のシーンを見つけ出すことは、どれほど記憶力に優れた人にとっても非常に困難な作業です。そこで役に立つのが、ビデオ理解(Video Understanding)テクノロジーです 🛟⛑️。

Twelve LabsのSearch APIは、ユーザーがシンプルな自然言語クエリを作成し、それらを独創的に組み合わせることで、関連するビデオセグメントを洗い出すことができる、柔軟なビデオ検索ソリューションを提供します。例えば、主演俳優が赤い三菱の車を運転する、特定のドリフトシーンを検出する複合クエリを作成することができます。あるいは、お気に入りのフォーミュラ1カーが勝利のチェッカーフラッグを切る、スリリングな瞬間を検索することも可能です 🏁✌️。

インデックス化された映画『ワイルド・スピードX3 TOKYO DRIFT』から、複合クエリ「'drift'(検索オプション:visual:視覚情報)AND 'Mitsubishi'(検索オプション:logo:ロゴ)」を実行した検索結果です 😎

はじめに

このチュートリアルシリーズの第1リファレンスでは、検索リクエストで一度に1つのクエリのみを使用する「シンプル検索」を使用して、ビデオ内の検索を行う方法を探りました。このフォローアップチュートリアルを最大限に活用するために、Twelve Labs Search APIの基本を理解するために前回のチュートリアルを復習することを強くお勧めします。基本を十分に理解していることを前提として、このチュートリアルではさらに高度な概念を紹介します。Twelve Labs APIが提供する「複合クエリ(Combined Queries)」機能について深く掘り下げていきます。これにより、インデックス化されたビデオ内から関心のある特定の瞬間を柔軟かつ便利に特定できます。これを示すために、私は2つの別々のインデックスを作成します。1つはフォーミュラ1のレース用、もう1つは映画『ワイルド・スピード』シリーズでお馴染みの本編『ワイルド・スピードX3 TOKYO DRIFT』用です。次に、さまざまな演算子を使用して検索クエリを組み合わせ、探している興味深い瞬間を特定する方法を示します。それでは、チュートリアルの概要に進み、このガイド全体で学べる内容を具体的に説明していきましょう。

クイック概要

  • 前提条件: Twelve Labs APIスイートにサインアップし、必要なパッケージをインストールして、このチュートリアルをスムーズに進められるようにしましょう。最初のチュートリアルも忘れずにチェックしてください!

  • ビデオのアップロード: Twelve Labsプラットフォームにビデオを送信すると、プラットフォームがビデオを簡単にインデックス化します。これにより、複雑な複合クエリを入力して、探している瞬間をフェッチすることができます!

  • 複合クエリ: ここからが本当の醍醐味です!複合クエリとは、簡潔に定義すると、「or」、「and」、「not」、「then」などの演算子を使用して、2つ以上のシンプルな検索クエリを結合して1つの統一されたクエリにしたものです。これらの演算子の理論的な側面を簡単に確認した後、それらを使用して2つ以上の自然言語クエリを効果的に結合する実用的な例を詳しく見ていきましょう。これにより、インデックス化されたビデオの中から複合クエリに意味的に一致する決定的な瞬間を見つけることができます。

  • デモアプリの作成: 検索APIからの結果を利用し、コンピュータのローカルフォルダに保存されているビデオにアクセスして、カスタム設計されたスマートなHTMLページをレンダリングし、検索結果をスタイリッシュに表示する、Flaskベースの便利なアプリを作成します。

💡 ちなみに、この記事を読んでいるあなたが開発者でなくても心配ありません!すぐに使えるJupyter notebookへのリンクを用意しています。クエリや演算子を簡単に調整してプロセス全体を実行し、結果を取得することができます 😄。お楽しみください!

前提条件

前回のチュートリアルが、このチュートリアルの唯一の前提条件です。これらを読んでいる最中に何かつまずくことがあれば、遠慮なくサポートを求めてください!私たちのDiscordサーバーでは、非常に迅速な対応を行っています 🚅🏎️⚡️。Discordがお好みに合わない場合は、メールでお気軽にお問い合わせください。Twelve Labsアカウントを作成すると、APIダッシュボードにアクセスしてAPIキーを取得できます。このデモでは、私の既存のアカウントを使用します:

<pre><code class="bash">%env API_KEY=<your_API_key>
%env API_URL=https://api.twelvelabs.io/v1.1
</code></pre>
<pre><code class="python">!pip install requests
!pip install flask

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>

ビデオのアップロード

これは最初のステップです。ここでは、最新の最先端ビデオ理解エンジン「Marengo 2.5」を使用して2つのインデックスを作成しますが、それぞれ異なるインデックス作成オプションを適用します。フォーミュラ1のレースに焦点を当てたインデックスでは、視覚情報(visual)と音声対話(conversation)に加えて、ビデオ内テキスト(text-in-video)とロゴ(logo)オプションを有効にすることが有益です。フォーミュラ1イベントには、車両、トラック、フェンス、そして表彰式中の画面上に表示される大量のテキストなど、ロゴが豊富に含まれているためです。しかし、映画『ワイルド・スピードX3 TOKYO DRIFT』のインデックスでは、ビデオ内テキスト(text-in-video)オプションを有効にしても価値がない可能性があります。ここで、異なるオプションでインデックスを作成する柔軟性が役に立ちます。特定のニーズに合わせてインデックス作成オプションをカスタマイズすることで、計算リソースの使用を最適化し、最終的にコストを節約できます。

<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

# Specify the names of the indexes
index_names = ["formula_one", "tokyo_drift"]

# Create the indexes
index_id_formula_one = create_index(index_name = "formula_one", index_options=["visual", "conversation", "text_in_video", "logo"], engine = "marengo2.5")
index_id_tokyo_drift = create_index(index_name = "tokyo_drift", index_options=["visual", "conversation", "logo"], engine = "marengo2.5")

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

出力:

<pre><code class="language-plaintext">Status code: 201 - The request was successful and a new resource was created.
{'_id': '##38fb695b65d57eaecaf8##'}
Status code: 201 - The request was successful and a new resource was created.
{'_id': '##38fb695b65d57eaecaf8##'}
Created index IDs: ##38fb695b65d57eaecaf8##, ##38fb695b65d57eaecaf8##
</code></pre>

ビデオインデックス作成タスクの開始

特定のフォルダからすべてのビデオを自動的に取り込み、ビデオファイル自体と同じ名前を割り当て、非同期でプラットフォームにアップロードするようにコードを設定しました。インデックスに含めたいすべてのビデオを1つのフォルダに配置してください。並行アップロード用のスレッドを作成せずに非同期で「for」ループを使用してビデオをアップロードしたとしても、システムはそれらを同期(同時)にインデックス化するため、総インデックス作成時間は最も長いビデオの長さの約40%になります。後で同じインデックス内にさらに多くのビデオをインデックス化したい場合も問題ありません!新しいビデオファイル用に新しいフォルダを作成する必要はありません。既存のフォルダに追加するだけで、インデックス作成を開始する前に、同じ名前のインデックス化されたビデオがすでに存在するか、または同じ名前のビデオに対する保留中のインデックス作成タスクがあるかどうかがコードによってチェックされます。これにより重複を回避できます。素晴らしいですよね? 😄

<pre><code class="python">TASKS_URL = f"{API_URL}/tasks"
TASK_ID_LIST = []
video_folder = 'static'  # folder containing the video files
INDEX_ID = index_id_tom  # change this to the other index id while creating the index for lex fridman podcast videos
# INDEX_ID = '##38d9c4e4225d1c0eb1e8##'

# Iterate through all the video files in the folder
for file_name in os.listdir(video_folder):
    # 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")
            continue

    #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")
</code></pre>

出力:

<pre><code class="language-plaintext">Entering task creation code for the file:  20211113T190000Z.mp4
Status code: 201 - The request was successful and a new resource was created.
File name: 20211113T190000Z.mp4
{'_id': '6438fb6e5b65d57eaecaf8bc'}


Entering task creation code for the file:  20211113T193300Z.mp4
Status code: 201 - The request was successful and a new resource was created.
File name: 20211113T193300Z.mp4
{'_id': '##38fb755b65d57eaecaf8##'}
</code></pre>

インデックス作成プロセスの監視

監視機能は、現在インデックス作成中であるビデオの推定残り時間を表示するように設計しました。インデックス作成タスクが完了すると、監視プロセスは次のビデオインデックス作成タスクへと移行します。これはシステムが並行処理のアプローチをとっているため、次のタスクはすでに進行中です。フォルダ内のすべてのビデオがインデックス化されるまで、このプロセスが継続されます。最後に、この同期インデックス作成にかかった総時間が秒単位で表示されます。

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

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

# Monitor the indexing process for all tasks
for task_id in TASK_ID_LIST:
    print("Current Task being monitored: ", task_id)
    monitor_upload_status(task_id)

# Define ending time
end = time.time()
print("Uploading finished")
print("Time elapsed (in seconds): ", end - start)
</code></pre>
<pre><code class="language-plaintext">Starting to monitor...
Current Task being monitored:  ##38fb6e5b65d57eaecaf8##
........Remaining seconds: 264.48919677734375, Upload Percentage: 0
Remaining seconds: 258.351806640625, Upload Percentage: 2
Remaining seconds: 253.1555633544922, Upload Percentage: 4
Remaining seconds: 247.93516540527344, Upload Percentage: 6
Remaining seconds: 242.26431274414062, Upload Percentage: 8
Remaining seconds: 237.22894287109375, Upload Percentage: 10
Remaining seconds: 231.01914978027344, Upload Percentage: 12
Remaining seconds: 224.7932891845703, Upload Percentage: 15
Remaining seconds: 218.599609375, Upload Percentage: 17
</code></pre>

インデックス内のすべてのビデオの一覧表示

必要なすべてのビデオがインデックス化されたことを確認するために、インデックス内に存在するすべてのビデオを一覧表示してダブルチェックしましょう。さらに、すべてのビデオIDとそれに対応する名前を含むリストを作成します。これは、対応するビデオのクリップ(開始タイムスタンプと終了タイムスタンプ付きで表示される)を取得し、表示するために後で使用します。

<pre><code class="python"># List all the videos in an index
INDEX_ID='##38d9c4e4225d1c0eb1e8##'
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']]

print(video_id_name_list)
</code></pre>
<pre><code class="bash">{'data': [{'_id': '##3d978c86daab572f3481##',
           'created_at': '2023-04-17T18:56:51Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211113T190000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 415876158,
                        'width': 704},
           'updated_at': '2023-04-17T19:01:32Z'},
          {'_id': '##3d975786daab572f3481##',
           'created_at': '2023-04-17T18:56:44Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211114T170000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 387273943,
                        'width': 704},
           'updated_at': '2023-04-17T19:00:39Z'},
          {'_id': '##3d972e86daab572f3481##',
           'created_at': '2023-04-17T18:56:38Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211113T193000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 386209689,
                        'width': 704},
           'updated_at': '2023-04-17T18:59:58Z'},
          {'_id': '##3d96d386daab572f3481##',
           'created_at': '2023-04-17T18:56:28Z',
           'metadata': {'duration': 1800.52,
                        'engine_id': 'marengo2.5',
                        'filename': '20211121T133000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 348611416,
                        'width': 704},
           'updated_at': '2023-04-17T18:58:27Z'},
          {'_id': '##3d96af86daab572f3481##',
           'created_at': '2023-04-17T18:56:08Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211113T200000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 327766175,
                        'width': 704},
           'updated_at': '2023-04-17T18:57:51Z'}],
 'page_info': {'limit_per_page': 10,
               'page': 1,
               'total_duration': 9002.76,
               'total_page': 1,
               'total_results': 5}}

[{'video_id': '##3d978c86daab572f3481##', 'video_name': '20211113T190000Z.mp4'},
 {'video_id': '##3d975786daab572f3481##', 'video_name': '20211114T170000Z.mp4'},
 {'video_id': '##3d972e86daab572f3481##', 'video_name': '20211113T193000Z.mp4'},
 {'video_id': '##3d96d386daab572f3481##', 'video_name': '20211121T133000Z.mp4'},
 {'video_id': '##3d96af86daab572f3481##', 'video_name': '20211113T200000Z.mp4'}]
 </code></pre>

複合クエリ

システムがビデオのインデックス作成とビデオの分散表現(埋め込み表現、embedding)の生成を完了したら、検索APIを使用して、意味的に一致する最も優れた瞬間を見つける準備が整います。前回のチュートリアルでは、シンプルなクエリの使用方法について紹介しました。ここでは、実用的な「複合クエリ」を作成することに焦点を当てます。

検索APIを使用すると、次の演算子を使用して複合クエリを構築できます。

  • AND:この演算子は、単純クエリの積集合を表します。例えば、「赤い車(a red car)」と「青い車(a blue car)」という2つの単純クエリを「and」演算子で組み合わせると、赤い車と青い車の両方が存在するすべてのシーンがフェッチされます。

  • OR:この演算子は、単純クエリの和集合に使用されます。2つの単純クエリ「赤い車」と「青い車」を「or」演算子で組み合わせると、赤い車または青い車のいずれかが存在するすべてのシーンがフェッチされます。

  • NOT:この演算子を使用するには、キーを $not 文字列とし、値を originsub という2つのクエリで構成されたディクショナリ(辞書)にした、ディクショナリを構築する必要があります。APIは、origin クエリには一致するが、sub クエリには一致しないビデオクリップを返します。「赤い車」を originとし、「青い車」を sub とした場合、赤い車は存在するが、青い車は存在しないビデオクリップをフェッチします。なお、 originsub クエリの両方に、任意の数のサブクエリを含めることができます。

  • THEN:この演算子は、キーを $then 文字列とし、値をオブジェクトの配列(各オブジェクトがサブクエリを表す)とした、ディクショナリを構築することで使用できます。APIは、一致するビデオ断片の順序がサブクエリの順序と一致する場合にのみ結果を返します。つまり私たちが使用している例の場合、赤い車が表示され、その後に青い車が続くという決定的なシーケンスを持つビデオクリップが返されます。

理論的な部分は以上です。それでは、実際に複合クエリを使用して最初の検索を実行し、その優れた応用性能を目の当たりにしましょう。この複合クエリは、異なる検索オプションを持つ2つの単純クエリを「AND」演算子を使用して組み合わせたものです。最初のクエリは、音声と視覚の両方において「トロフィーを獲得する(winning a trophy)」という概念と意味的に類似しているシーンを探すためのものです。一方、2番目のクエリは「crypto.com」というテキストまたはロゴが含まれるシーンを探すためのものです。これらのクエリを組み合わせることで、両方の基準を同時に満たすビデオクリップを見つけることができます。

<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,
    "search_options": ["visual"],
    "query": {
                "$and": [
                    {
                        "text": "winning trophy",
                        "search_options": ["visual"]
                    },
                    {
                        "text": "crypto.com",
                        "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())
</code></pre>

出力:

<pre><code class="bash">Status code: 200 - Success
{'data': [{'confidence': 'high',
           'end': 20,
           'score': 92.28,
           'start': 18,
           'video_id': '##3d96af86daab572f3481##'},
          {'confidence': 'high',
           'end': 43,
           'score': 92.28,
           'start': 42,
           'video_id': '##3d978c86daab572f3481##'},
          {'confidence': 'high',
           'end': 71,
           'score': 92.28,
           'start': 61,
           'video_id': '##3d978c86daab572f3481##'},
          {'confidence': 'high',
           'end': 62,
           'score': 92.28,
           'start': 61,
           'video_id': '##3d96af86daab572f3481##'},
          {'confidence': 'high',
           'end': 67,
           'score': 92.28,
           'start': 65,           
           'video_id': '##3d96af86daab572f3481##'}],
 'page_info': {'limit_per_page': 10,
               'next_page_token': '##69daa3-827f-4165-982d-ec0d34f97c7c-1',
               'page_expired_at': '2023-04-17T23:56:00Z',
               'total_results': 110},
 'search_pool': {'index_id': '##3d9556f607a5a7bd9ea5##',
                 'total_count': 5,
                 'total_duration': 9003}}
</code></pre>

対応するビデオクリップ:

<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/1a08de21a4d14f85ab3ee125660438da" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>

<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/e2cb3c58f49c4b00b0c6c5b2f745acfc" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>



この部分は、知能(intelligence)の存在を浮き彫りにするため、私を非常にワクワクさせてくれます。このモデルは、ビデオコンテンツに対して人間のような理解を示します。上記のスクショ(ビデオ)でご覧いただけるように、システムが私が抽出したかったまさにその瞬間を特定することで、完璧に成功を収めました。

今度は、Tokyo Drift の本編を含む2番目のインデックスに対して、単純クエリのセットを結合して具体的に検索を行ってみましょう:

<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,
    "search_options": ["visual"],
    "query": {
                "$and": [
                    {
                        "text": "drift",
                        "search_options": ["visual"]
                    },
                    {
                        "text": "mitsubishi",
                        "search_options": ["logo"]
                    }
                ]
            }
        }
# 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())
</code></pre>

出力:

<pre><code class="bash">Status code: 200 - Success
{'data': [{'confidence': 'high',
           'end': 3710,
           'score': 92.28,
           'start': 3705,
           'video_id': '##3e3ace86daab572f3481##'}],
 'page_info': {'limit_per_page': 10,
               'page_expired_at': '2023-04-18T09:09:59Z',
               'total_results': 1},
 'search_pool': {'index_id': '##3e3647f607a5a7bd9ea5##',
                 'total_count': 1,
                 'total_duration': 6246}}
</code></pre>

対応するビデオクリップ:

<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/a7fc79ff424f4d50b7a42dc2bd134473" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>

当たりです!今回も、システムが完璧な瞬間を正確に特定しました。このシーンでは、主演俳優のショーン(ルーカス・ブラック)が、赤い三菱自動車を巧みにドリフトさせています。

各ビデオのID、対応するタイトル、およびそれぞれの開始タイムスタンプと終了タイムスタンプを含むPythonのリストを準備しましょう。次のステップでこのリストをFlaskアプリに渡し、検索結果をウェブページに表示できるようにします:

<pre><code class="python">response_data = response.json()  

# Extract unique video IDs
unique_video_ids = list(set([item['video_id'] for item in response_data['data']]))

# Create empty start and end instances lists for each video ID
video_start_end_lists = {video_id: {'starts': [], 'ends': []} for video_id in unique_video_ids}

def find_video_name(video_id, video_id_name_list):
    for video in video_id_name_list:
        if video['video_id'] == video_id:
            return video['video_name']
    return None

# Append start and end instances to their respective lists
for item in response_data['data']:
    video_id = item['video_id']
    video_start_end_lists[video_id]['starts'].append(item['start'])
    video_start_end_lists[video_id]['ends'].append(item['end'])

for video_id, timestamps in video_start_end_lists.items():
    video_name = find_video_name(video_id, video_id_name_list)
    if video_name:
        timestamps['video_name'] = video_name
    else:
        print(f"No video name found for ID '{video_id}'")

# Print the result
pprint(video_start_end_lists)
</code></pre>
<pre><code class="bash">{'##3d96af86daab572f3481##': {'ends': [20, 62, 67, 114],
                              'starts': [18, 61, 65, 111],
                              'video_name': '20211113T200000Z.mp4'},
 '643d972e86daab572f34810d': {'ends': [84],
                              'starts': [68],
                              'video_name': '20211113T193000Z.mp4'},
 '643d975786daab572f34810e': {'ends': [79],
                              'starts': [70],
                              'video_name': '20211114T170000Z.mp4'},
 '643d978c86daab572f34810f': {'ends': [43, 71, 85, 95],
                              'starts': [42, 61, 84, 91],
                              'video_name': '20211113T190000Z.mp4'}}
</code></pre>

後でFlaskアプリで使用するためにリストを保存するには、リストをファイルにシリアル化(オブジェクトの永続化、pickle化)することができます:



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

with open("lists.pkl", "wb") as f:
    pickle.dump(video_start_end_lists, f)
</code></pre>

デモアプリの作成

いよいよ最終ステップです。受信したJSONレスポンスを利用して、開始点と終了点を手動で特定することなく、ビデオクリップを効率的に取得および表示します。これを実現するために、これらのタイムスタンプを利用してローカルドライブから取得したビデオに適用できるウェブページをホストします。その結果、検索に一致する魅力的なビデオクリップがウェブページ上にシームレスに表示されます。

ディレクトリ構造は以下のようになります。

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

Flaskアプリのコード

以下は、「app.py」ファイルのコードです:

<pre><code class="python">
from flask import Flask, render_template
import pickle

with open("lists.pkl", "rb") as f:
   video_start_end_lists = pickle.load(f)

app = Flask(__name__)

@app.route("/")
def index():
    return render_template("index_local.html", video_start_end_lists=video_start_end_lists)

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

HTMLテンプレート

以下は、先ほど準備したリストを反復処理し、ローカルドライブから必要なビデオを取得し、複合クエリの結果を表示するコードをHTMLファイル内に組み込んだ、Jinja2ベースのサンプルHTMLテンプレートです:

<pre><code class="language-html"><!DOCTYPE html>
    <html>
      <head>
        <link rel="shortcut icon" href="#" />
        <style>
          body {
            background-color: #FFE0B2; /* Light Orange */
            font-family: Arial, sans-serif;
            text-align: center;
            margin: 0;
          }
          h1 {
            font-size: 3em;
            color: #000000; /* Black */
            background-color: #9ACD32; /* Light Green */
            padding: 20px;
            margin: 0;
          }
          h2 {
            font-size: 2em;
            color: #000000; /* Black */
            margin-bottom: 20px;
            text-align: left;
            padding-left: 20px;
          }
          .video-container {
            display: flex;
            flex-wrap: wrap;
            padding: 2px;
            justify-content: space-evenly;
            gap: 2px;
          }
          .video-item {
            display: flex;
            flex-direction: column;
            align-items: center;
            width: 45%;
            height: 450px;
            margin: 20px;
            text-align: center;
            background-color: #FFFFFF;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
            border-radius: 10px;
            padding: 20px;
          }
          .video-item video {
            width: 100%;
            height: 380px;
            margin: 0; /* Remove bottom margin */
            border-radius: 5px;
          }
          .video-item p {
            font-size: 16px;
            margin-top: 10px;
            font-weight: bold;
            color: #212121; /* Dark Grey */
          }
          .video-item span {
            color: #9ACD32; /* Light Green */
          }
        </style>
      </head>
      <body>
        <h1>My Favorite Scenes</h1>
        {% for video_id, segments in video_start_end_lists.items() %}
        <div class="video-section">
          <h2>Scenes from {{segments['video_name']}}</h2>
          <div class="video-container">
            {% for i in range(segments['starts']|length) %}
              <div class="video-item">
                <video id="video_{{ video_id }}_{{ i }}" width="560" height="315" controls>
                  <source src="{{ url_for('static', filename= segments['video_name']) }}" type="video/mp4">
                  Your browser does not support the video tag.
                </video>
                <p>Start: <span>{{ segments['starts'][i] }}</span> | End: <span>{{ segments['ends'][i] }}</span></p>
                <script>
                  document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("loadedmetadata", function() {
                    this.currentTime = {{ segments['starts'][i] }};
                  });
                  document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("timeupdate", function() {
                    if (this.currentTime >= {{ segments['ends'][i] }}) {
                      this.pause();
                    }
                  });
                </script>
              </div>
            {% endfor %}
          </div>
        </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 をクリックすると、複合検索クエリに応じて、以下のように出力が表示されます:

このチュートリアルを通じて構築した完全なコードを含むJupyter Notebookはこちらからアクセスできます - https://tinyurl.com/combinedQueries

試してみると面白いアクティビティ

  1. 検索オプションと演算子のさまざまな順列や組み合わせを試してみて、Discordチャンネルに参加している他のマルチモーダル愛好家と分析結果を共有しましょう。

  2. 単純クエリの表現バリエーションを試して、結果が一貫しているか、あるいは異なるかを確認します。クエリを組み合わせる際、クエリ内の言語的なニュアンスが、検索結果や一致するビデオクリップの精度にどのように影響するかを探ってみましょう。

  3. コードを拡張して、開発者としての実力を発揮しましょう!すべてのビデオを一度に並行してアップロードするメカニズムを構築し、それに応じてインデックス作成プロセスを監視するようにコードを修正します。

次回の予告

次回の投稿では、Classification API(分類API)を詳しく掘り下げ、リアルタイムで分類基準を作成してビデオセットを効果的に分類する方法を構築します。次回の面白いコンテンツも楽しみにお待ちください。マルチモーダル基盤モデルに情熱を持つ同じ考えを持つ人々と繋がるために、私たちのDiscordコミュニティへの参加もお忘れなく。

前提

映画ファン🎬🍿でありコンテンツクリエイター🎨🖌️✍️でもある私は、お気に入りの映画コレクションを保管するために自分自身のPlexサーバーを構築しました。ストーリーテリングを引き立て、より魅力的なコンテンツを作成するために、映画のシーンを逸話として使用することがよくあります。例えば、モチベーションや意志の強さ、困難の克服をテーマにした動画を作る場合、大人気アドベンチャーアニメ『ドラゴンボール超』のエキサイティングな超サイヤ人への変身シーンや、お気に入りの映画『ネバー・バックダウン』のワークアウトやトレーニングのシーンなど、関連する瞬間を紹介するかもしれません。あるいは、新しい映画の脚本を制作中の映画監督や脚本家が、類似する映画のセットを分析して、コメディシーンの数、その長さ、ドリフトレースの発生、マッスルカーが登場する頻度といった、共通のテーマやパターンを特定したいと考える場合もあるでしょう。膨大な映画作品の中から、あるいは1本の映画の中から特定のシーンを見つけ出すことは、どれほど記憶力に優れた人にとっても非常に困難な作業です。そこで役に立つのが、ビデオ理解(Video Understanding)テクノロジーです 🛟⛑️。

Twelve LabsのSearch APIは、ユーザーがシンプルな自然言語クエリを作成し、それらを独創的に組み合わせることで、関連するビデオセグメントを洗い出すことができる、柔軟なビデオ検索ソリューションを提供します。例えば、主演俳優が赤い三菱の車を運転する、特定のドリフトシーンを検出する複合クエリを作成することができます。あるいは、お気に入りのフォーミュラ1カーが勝利のチェッカーフラッグを切る、スリリングな瞬間を検索することも可能です 🏁✌️。

インデックス化された映画『ワイルド・スピードX3 TOKYO DRIFT』から、複合クエリ「'drift'(検索オプション:visual:視覚情報)AND 'Mitsubishi'(検索オプション:logo:ロゴ)」を実行した検索結果です 😎

はじめに

このチュートリアルシリーズの第1リファレンスでは、検索リクエストで一度に1つのクエリのみを使用する「シンプル検索」を使用して、ビデオ内の検索を行う方法を探りました。このフォローアップチュートリアルを最大限に活用するために、Twelve Labs Search APIの基本を理解するために前回のチュートリアルを復習することを強くお勧めします。基本を十分に理解していることを前提として、このチュートリアルではさらに高度な概念を紹介します。Twelve Labs APIが提供する「複合クエリ(Combined Queries)」機能について深く掘り下げていきます。これにより、インデックス化されたビデオ内から関心のある特定の瞬間を柔軟かつ便利に特定できます。これを示すために、私は2つの別々のインデックスを作成します。1つはフォーミュラ1のレース用、もう1つは映画『ワイルド・スピード』シリーズでお馴染みの本編『ワイルド・スピードX3 TOKYO DRIFT』用です。次に、さまざまな演算子を使用して検索クエリを組み合わせ、探している興味深い瞬間を特定する方法を示します。それでは、チュートリアルの概要に進み、このガイド全体で学べる内容を具体的に説明していきましょう。

クイック概要

  • 前提条件: Twelve Labs APIスイートにサインアップし、必要なパッケージをインストールして、このチュートリアルをスムーズに進められるようにしましょう。最初のチュートリアルも忘れずにチェックしてください!

  • ビデオのアップロード: Twelve Labsプラットフォームにビデオを送信すると、プラットフォームがビデオを簡単にインデックス化します。これにより、複雑な複合クエリを入力して、探している瞬間をフェッチすることができます!

  • 複合クエリ: ここからが本当の醍醐味です!複合クエリとは、簡潔に定義すると、「or」、「and」、「not」、「then」などの演算子を使用して、2つ以上のシンプルな検索クエリを結合して1つの統一されたクエリにしたものです。これらの演算子の理論的な側面を簡単に確認した後、それらを使用して2つ以上の自然言語クエリを効果的に結合する実用的な例を詳しく見ていきましょう。これにより、インデックス化されたビデオの中から複合クエリに意味的に一致する決定的な瞬間を見つけることができます。

  • デモアプリの作成: 検索APIからの結果を利用し、コンピュータのローカルフォルダに保存されているビデオにアクセスして、カスタム設計されたスマートなHTMLページをレンダリングし、検索結果をスタイリッシュに表示する、Flaskベースの便利なアプリを作成します。

💡 ちなみに、この記事を読んでいるあなたが開発者でなくても心配ありません!すぐに使えるJupyter notebookへのリンクを用意しています。クエリや演算子を簡単に調整してプロセス全体を実行し、結果を取得することができます 😄。お楽しみください!

前提条件

前回のチュートリアルが、このチュートリアルの唯一の前提条件です。これらを読んでいる最中に何かつまずくことがあれば、遠慮なくサポートを求めてください!私たちのDiscordサーバーでは、非常に迅速な対応を行っています 🚅🏎️⚡️。Discordがお好みに合わない場合は、メールでお気軽にお問い合わせください。Twelve Labsアカウントを作成すると、APIダッシュボードにアクセスしてAPIキーを取得できます。このデモでは、私の既存のアカウントを使用します:

<pre><code class="bash">%env API_KEY=<your_API_key>
%env API_URL=https://api.twelvelabs.io/v1.1
</code></pre>
<pre><code class="python">!pip install requests
!pip install flask

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>

ビデオのアップロード

これは最初のステップです。ここでは、最新の最先端ビデオ理解エンジン「Marengo 2.5」を使用して2つのインデックスを作成しますが、それぞれ異なるインデックス作成オプションを適用します。フォーミュラ1のレースに焦点を当てたインデックスでは、視覚情報(visual)と音声対話(conversation)に加えて、ビデオ内テキスト(text-in-video)とロゴ(logo)オプションを有効にすることが有益です。フォーミュラ1イベントには、車両、トラック、フェンス、そして表彰式中の画面上に表示される大量のテキストなど、ロゴが豊富に含まれているためです。しかし、映画『ワイルド・スピードX3 TOKYO DRIFT』のインデックスでは、ビデオ内テキスト(text-in-video)オプションを有効にしても価値がない可能性があります。ここで、異なるオプションでインデックスを作成する柔軟性が役に立ちます。特定のニーズに合わせてインデックス作成オプションをカスタマイズすることで、計算リソースの使用を最適化し、最終的にコストを節約できます。

<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

# Specify the names of the indexes
index_names = ["formula_one", "tokyo_drift"]

# Create the indexes
index_id_formula_one = create_index(index_name = "formula_one", index_options=["visual", "conversation", "text_in_video", "logo"], engine = "marengo2.5")
index_id_tokyo_drift = create_index(index_name = "tokyo_drift", index_options=["visual", "conversation", "logo"], engine = "marengo2.5")

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

出力:

<pre><code class="language-plaintext">Status code: 201 - The request was successful and a new resource was created.
{'_id': '##38fb695b65d57eaecaf8##'}
Status code: 201 - The request was successful and a new resource was created.
{'_id': '##38fb695b65d57eaecaf8##'}
Created index IDs: ##38fb695b65d57eaecaf8##, ##38fb695b65d57eaecaf8##
</code></pre>

ビデオインデックス作成タスクの開始

特定のフォルダからすべてのビデオを自動的に取り込み、ビデオファイル自体と同じ名前を割り当て、非同期でプラットフォームにアップロードするようにコードを設定しました。インデックスに含めたいすべてのビデオを1つのフォルダに配置してください。並行アップロード用のスレッドを作成せずに非同期で「for」ループを使用してビデオをアップロードしたとしても、システムはそれらを同期(同時)にインデックス化するため、総インデックス作成時間は最も長いビデオの長さの約40%になります。後で同じインデックス内にさらに多くのビデオをインデックス化したい場合も問題ありません!新しいビデオファイル用に新しいフォルダを作成する必要はありません。既存のフォルダに追加するだけで、インデックス作成を開始する前に、同じ名前のインデックス化されたビデオがすでに存在するか、または同じ名前のビデオに対する保留中のインデックス作成タスクがあるかどうかがコードによってチェックされます。これにより重複を回避できます。素晴らしいですよね? 😄

<pre><code class="python">TASKS_URL = f"{API_URL}/tasks"
TASK_ID_LIST = []
video_folder = 'static'  # folder containing the video files
INDEX_ID = index_id_tom  # change this to the other index id while creating the index for lex fridman podcast videos
# INDEX_ID = '##38d9c4e4225d1c0eb1e8##'

# Iterate through all the video files in the folder
for file_name in os.listdir(video_folder):
    # 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")
            continue

    #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")
</code></pre>

出力:

<pre><code class="language-plaintext">Entering task creation code for the file:  20211113T190000Z.mp4
Status code: 201 - The request was successful and a new resource was created.
File name: 20211113T190000Z.mp4
{'_id': '6438fb6e5b65d57eaecaf8bc'}


Entering task creation code for the file:  20211113T193300Z.mp4
Status code: 201 - The request was successful and a new resource was created.
File name: 20211113T193300Z.mp4
{'_id': '##38fb755b65d57eaecaf8##'}
</code></pre>

インデックス作成プロセスの監視

監視機能は、現在インデックス作成中であるビデオの推定残り時間を表示するように設計しました。インデックス作成タスクが完了すると、監視プロセスは次のビデオインデックス作成タスクへと移行します。これはシステムが並行処理のアプローチをとっているため、次のタスクはすでに進行中です。フォルダ内のすべてのビデオがインデックス化されるまで、このプロセスが継続されます。最後に、この同期インデックス作成にかかった総時間が秒単位で表示されます。

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

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

# Monitor the indexing process for all tasks
for task_id in TASK_ID_LIST:
    print("Current Task being monitored: ", task_id)
    monitor_upload_status(task_id)

# Define ending time
end = time.time()
print("Uploading finished")
print("Time elapsed (in seconds): ", end - start)
</code></pre>
<pre><code class="language-plaintext">Starting to monitor...
Current Task being monitored:  ##38fb6e5b65d57eaecaf8##
........Remaining seconds: 264.48919677734375, Upload Percentage: 0
Remaining seconds: 258.351806640625, Upload Percentage: 2
Remaining seconds: 253.1555633544922, Upload Percentage: 4
Remaining seconds: 247.93516540527344, Upload Percentage: 6
Remaining seconds: 242.26431274414062, Upload Percentage: 8
Remaining seconds: 237.22894287109375, Upload Percentage: 10
Remaining seconds: 231.01914978027344, Upload Percentage: 12
Remaining seconds: 224.7932891845703, Upload Percentage: 15
Remaining seconds: 218.599609375, Upload Percentage: 17
</code></pre>

インデックス内のすべてのビデオの一覧表示

必要なすべてのビデオがインデックス化されたことを確認するために、インデックス内に存在するすべてのビデオを一覧表示してダブルチェックしましょう。さらに、すべてのビデオIDとそれに対応する名前を含むリストを作成します。これは、対応するビデオのクリップ(開始タイムスタンプと終了タイムスタンプ付きで表示される)を取得し、表示するために後で使用します。

<pre><code class="python"># List all the videos in an index
INDEX_ID='##38d9c4e4225d1c0eb1e8##'
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']]

print(video_id_name_list)
</code></pre>
<pre><code class="bash">{'data': [{'_id': '##3d978c86daab572f3481##',
           'created_at': '2023-04-17T18:56:51Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211113T190000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 415876158,
                        'width': 704},
           'updated_at': '2023-04-17T19:01:32Z'},
          {'_id': '##3d975786daab572f3481##',
           'created_at': '2023-04-17T18:56:44Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211114T170000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 387273943,
                        'width': 704},
           'updated_at': '2023-04-17T19:00:39Z'},
          {'_id': '##3d972e86daab572f3481##',
           'created_at': '2023-04-17T18:56:38Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211113T193000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 386209689,
                        'width': 704},
           'updated_at': '2023-04-17T18:59:58Z'},
          {'_id': '##3d96d386daab572f3481##',
           'created_at': '2023-04-17T18:56:28Z',
           'metadata': {'duration': 1800.52,
                        'engine_id': 'marengo2.5',
                        'filename': '20211121T133000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 348611416,
                        'width': 704},
           'updated_at': '2023-04-17T18:58:27Z'},
          {'_id': '##3d96af86daab572f3481##',
           'created_at': '2023-04-17T18:56:08Z',
           'metadata': {'duration': 1800.56,
                        'engine_id': 'marengo2.5',
                        'filename': '20211113T200000Z.mp4',
                        'fps': 25,
                        'height': 396,
                        'size': 327766175,
                        'width': 704},
           'updated_at': '2023-04-17T18:57:51Z'}],
 'page_info': {'limit_per_page': 10,
               'page': 1,
               'total_duration': 9002.76,
               'total_page': 1,
               'total_results': 5}}

[{'video_id': '##3d978c86daab572f3481##', 'video_name': '20211113T190000Z.mp4'},
 {'video_id': '##3d975786daab572f3481##', 'video_name': '20211114T170000Z.mp4'},
 {'video_id': '##3d972e86daab572f3481##', 'video_name': '20211113T193000Z.mp4'},
 {'video_id': '##3d96d386daab572f3481##', 'video_name': '20211121T133000Z.mp4'},
 {'video_id': '##3d96af86daab572f3481##', 'video_name': '20211113T200000Z.mp4'}]
 </code></pre>

複合クエリ

システムがビデオのインデックス作成とビデオの分散表現(埋め込み表現、embedding)の生成を完了したら、検索APIを使用して、意味的に一致する最も優れた瞬間を見つける準備が整います。前回のチュートリアルでは、シンプルなクエリの使用方法について紹介しました。ここでは、実用的な「複合クエリ」を作成することに焦点を当てます。

検索APIを使用すると、次の演算子を使用して複合クエリを構築できます。

  • AND:この演算子は、単純クエリの積集合を表します。例えば、「赤い車(a red car)」と「青い車(a blue car)」という2つの単純クエリを「and」演算子で組み合わせると、赤い車と青い車の両方が存在するすべてのシーンがフェッチされます。

  • OR:この演算子は、単純クエリの和集合に使用されます。2つの単純クエリ「赤い車」と「青い車」を「or」演算子で組み合わせると、赤い車または青い車のいずれかが存在するすべてのシーンがフェッチされます。

  • NOT:この演算子を使用するには、キーを $not 文字列とし、値を originsub という2つのクエリで構成されたディクショナリ(辞書)にした、ディクショナリを構築する必要があります。APIは、origin クエリには一致するが、sub クエリには一致しないビデオクリップを返します。「赤い車」を originとし、「青い車」を sub とした場合、赤い車は存在するが、青い車は存在しないビデオクリップをフェッチします。なお、 originsub クエリの両方に、任意の数のサブクエリを含めることができます。

  • THEN:この演算子は、キーを $then 文字列とし、値をオブジェクトの配列(各オブジェクトがサブクエリを表す)とした、ディクショナリを構築することで使用できます。APIは、一致するビデオ断片の順序がサブクエリの順序と一致する場合にのみ結果を返します。つまり私たちが使用している例の場合、赤い車が表示され、その後に青い車が続くという決定的なシーケンスを持つビデオクリップが返されます。

理論的な部分は以上です。それでは、実際に複合クエリを使用して最初の検索を実行し、その優れた応用性能を目の当たりにしましょう。この複合クエリは、異なる検索オプションを持つ2つの単純クエリを「AND」演算子を使用して組み合わせたものです。最初のクエリは、音声と視覚の両方において「トロフィーを獲得する(winning a trophy)」という概念と意味的に類似しているシーンを探すためのものです。一方、2番目のクエリは「crypto.com」というテキストまたはロゴが含まれるシーンを探すためのものです。これらのクエリを組み合わせることで、両方の基準を同時に満たすビデオクリップを見つけることができます。

<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,
    "search_options": ["visual"],
    "query": {
                "$and": [
                    {
                        "text": "winning trophy",
                        "search_options": ["visual"]
                    },
                    {
                        "text": "crypto.com",
                        "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())
</code></pre>

出力:

<pre><code class="bash">Status code: 200 - Success
{'data': [{'confidence': 'high',
           'end': 20,
           'score': 92.28,
           'start': 18,
           'video_id': '##3d96af86daab572f3481##'},
          {'confidence': 'high',
           'end': 43,
           'score': 92.28,
           'start': 42,
           'video_id': '##3d978c86daab572f3481##'},
          {'confidence': 'high',
           'end': 71,
           'score': 92.28,
           'start': 61,
           'video_id': '##3d978c86daab572f3481##'},
          {'confidence': 'high',
           'end': 62,
           'score': 92.28,
           'start': 61,
           'video_id': '##3d96af86daab572f3481##'},
          {'confidence': 'high',
           'end': 67,
           'score': 92.28,
           'start': 65,           
           'video_id': '##3d96af86daab572f3481##'}],
 'page_info': {'limit_per_page': 10,
               'next_page_token': '##69daa3-827f-4165-982d-ec0d34f97c7c-1',
               'page_expired_at': '2023-04-17T23:56:00Z',
               'total_results': 110},
 'search_pool': {'index_id': '##3d9556f607a5a7bd9ea5##',
                 'total_count': 5,
                 'total_duration': 9003}}
</code></pre>

対応するビデオクリップ:

<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/1a08de21a4d14f85ab3ee125660438da" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>

<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/e2cb3c58f49c4b00b0c6c5b2f745acfc" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>



この部分は、知能(intelligence)の存在を浮き彫りにするため、私を非常にワクワクさせてくれます。このモデルは、ビデオコンテンツに対して人間のような理解を示します。上記のスクショ(ビデオ)でご覧いただけるように、システムが私が抽出したかったまさにその瞬間を特定することで、完璧に成功を収めました。

今度は、Tokyo Drift の本編を含む2番目のインデックスに対して、単純クエリのセットを結合して具体的に検索を行ってみましょう:

<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,
    "search_options": ["visual"],
    "query": {
                "$and": [
                    {
                        "text": "drift",
                        "search_options": ["visual"]
                    },
                    {
                        "text": "mitsubishi",
                        "search_options": ["logo"]
                    }
                ]
            }
        }
# 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())
</code></pre>

出力:

<pre><code class="bash">Status code: 200 - Success
{'data': [{'confidence': 'high',
           'end': 3710,
           'score': 92.28,
           'start': 3705,
           'video_id': '##3e3ace86daab572f3481##'}],
 'page_info': {'limit_per_page': 10,
               'page_expired_at': '2023-04-18T09:09:59Z',
               'total_results': 1},
 'search_pool': {'index_id': '##3e3647f607a5a7bd9ea5##',
                 'total_count': 1,
                 'total_duration': 6246}}
</code></pre>

対応するビデオクリップ:

<div style="position: relative; padding-bottom: 47.8125%; height: 0;"><iframe src="https://www.loom.com/embed/a7fc79ff424f4d50b7a42dc2bd134473" frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;"></iframe></div>

当たりです!今回も、システムが完璧な瞬間を正確に特定しました。このシーンでは、主演俳優のショーン(ルーカス・ブラック)が、赤い三菱自動車を巧みにドリフトさせています。

各ビデオのID、対応するタイトル、およびそれぞれの開始タイムスタンプと終了タイムスタンプを含むPythonのリストを準備しましょう。次のステップでこのリストをFlaskアプリに渡し、検索結果をウェブページに表示できるようにします:

<pre><code class="python">response_data = response.json()  

# Extract unique video IDs
unique_video_ids = list(set([item['video_id'] for item in response_data['data']]))

# Create empty start and end instances lists for each video ID
video_start_end_lists = {video_id: {'starts': [], 'ends': []} for video_id in unique_video_ids}

def find_video_name(video_id, video_id_name_list):
    for video in video_id_name_list:
        if video['video_id'] == video_id:
            return video['video_name']
    return None

# Append start and end instances to their respective lists
for item in response_data['data']:
    video_id = item['video_id']
    video_start_end_lists[video_id]['starts'].append(item['start'])
    video_start_end_lists[video_id]['ends'].append(item['end'])

for video_id, timestamps in video_start_end_lists.items():
    video_name = find_video_name(video_id, video_id_name_list)
    if video_name:
        timestamps['video_name'] = video_name
    else:
        print(f"No video name found for ID '{video_id}'")

# Print the result
pprint(video_start_end_lists)
</code></pre>
<pre><code class="bash">{'##3d96af86daab572f3481##': {'ends': [20, 62, 67, 114],
                              'starts': [18, 61, 65, 111],
                              'video_name': '20211113T200000Z.mp4'},
 '643d972e86daab572f34810d': {'ends': [84],
                              'starts': [68],
                              'video_name': '20211113T193000Z.mp4'},
 '643d975786daab572f34810e': {'ends': [79],
                              'starts': [70],
                              'video_name': '20211114T170000Z.mp4'},
 '643d978c86daab572f34810f': {'ends': [43, 71, 85, 95],
                              'starts': [42, 61, 84, 91],
                              'video_name': '20211113T190000Z.mp4'}}
</code></pre>

後でFlaskアプリで使用するためにリストを保存するには、リストをファイルにシリアル化(オブジェクトの永続化、pickle化)することができます:



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

with open("lists.pkl", "wb") as f:
    pickle.dump(video_start_end_lists, f)
</code></pre>

デモアプリの作成

いよいよ最終ステップです。受信したJSONレスポンスを利用して、開始点と終了点を手動で特定することなく、ビデオクリップを効率的に取得および表示します。これを実現するために、これらのタイムスタンプを利用してローカルドライブから取得したビデオに適用できるウェブページをホストします。その結果、検索に一致する魅力的なビデオクリップがウェブページ上にシームレスに表示されます。

ディレクトリ構造は以下のようになります。

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

Flaskアプリのコード

以下は、「app.py」ファイルのコードです:

<pre><code class="python">
from flask import Flask, render_template
import pickle

with open("lists.pkl", "rb") as f:
   video_start_end_lists = pickle.load(f)

app = Flask(__name__)

@app.route("/")
def index():
    return render_template("index_local.html", video_start_end_lists=video_start_end_lists)

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

HTMLテンプレート

以下は、先ほど準備したリストを反復処理し、ローカルドライブから必要なビデオを取得し、複合クエリの結果を表示するコードをHTMLファイル内に組み込んだ、Jinja2ベースのサンプルHTMLテンプレートです:

<pre><code class="language-html"><!DOCTYPE html>
    <html>
      <head>
        <link rel="shortcut icon" href="#" />
        <style>
          body {
            background-color: #FFE0B2; /* Light Orange */
            font-family: Arial, sans-serif;
            text-align: center;
            margin: 0;
          }
          h1 {
            font-size: 3em;
            color: #000000; /* Black */
            background-color: #9ACD32; /* Light Green */
            padding: 20px;
            margin: 0;
          }
          h2 {
            font-size: 2em;
            color: #000000; /* Black */
            margin-bottom: 20px;
            text-align: left;
            padding-left: 20px;
          }
          .video-container {
            display: flex;
            flex-wrap: wrap;
            padding: 2px;
            justify-content: space-evenly;
            gap: 2px;
          }
          .video-item {
            display: flex;
            flex-direction: column;
            align-items: center;
            width: 45%;
            height: 450px;
            margin: 20px;
            text-align: center;
            background-color: #FFFFFF;
            box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
            border-radius: 10px;
            padding: 20px;
          }
          .video-item video {
            width: 100%;
            height: 380px;
            margin: 0; /* Remove bottom margin */
            border-radius: 5px;
          }
          .video-item p {
            font-size: 16px;
            margin-top: 10px;
            font-weight: bold;
            color: #212121; /* Dark Grey */
          }
          .video-item span {
            color: #9ACD32; /* Light Green */
          }
        </style>
      </head>
      <body>
        <h1>My Favorite Scenes</h1>
        {% for video_id, segments in video_start_end_lists.items() %}
        <div class="video-section">
          <h2>Scenes from {{segments['video_name']}}</h2>
          <div class="video-container">
            {% for i in range(segments['starts']|length) %}
              <div class="video-item">
                <video id="video_{{ video_id }}_{{ i }}" width="560" height="315" controls>
                  <source src="{{ url_for('static', filename= segments['video_name']) }}" type="video/mp4">
                  Your browser does not support the video tag.
                </video>
                <p>Start: <span>{{ segments['starts'][i] }}</span> | End: <span>{{ segments['ends'][i] }}</span></p>
                <script>
                  document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("loadedmetadata", function() {
                    this.currentTime = {{ segments['starts'][i] }};
                  });
                  document.getElementById("video_{{ video_id }}_{{ i }}").addEventListener("timeupdate", function() {
                    if (this.currentTime >= {{ segments['ends'][i] }}) {
                      this.pause();
                    }
                  });
                </script>
              </div>
            {% endfor %}
          </div>
        </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 をクリックすると、複合検索クエリに応じて、以下のように出力が表示されます:

このチュートリアルを通じて構築した完全なコードを含むJupyter Notebookはこちらからアクセスできます - https://tinyurl.com/combinedQueries

試してみると面白いアクティビティ

  1. 検索オプションと演算子のさまざまな順列や組み合わせを試してみて、Discordチャンネルに参加している他のマルチモーダル愛好家と分析結果を共有しましょう。

  2. 単純クエリの表現バリエーションを試して、結果が一貫しているか、あるいは異なるかを確認します。クエリを組み合わせる際、クエリ内の言語的なニュアンスが、検索結果や一致するビデオクリップの精度にどのように影響するかを探ってみましょう。

  3. コードを拡張して、開発者としての実力を発揮しましょう!すべてのビデオを一度に並行してアップロードするメカニズムを構築し、それに応じてインデックス作成プロセスを監視するようにコードを修正します。

次回の予告

次回の投稿では、Classification API(分類API)を詳しく掘り下げ、リアルタイムで分類基準を作成してビデオセットを効果的に分類する方法を構築します。次回の面白いコンテンツも楽しみにお待ちください。マルチモーダル基盤モデルに情熱を持つ同じ考えを持つ人々と繋がるために、私たちのDiscordコミュニティへの参加もお忘れなく。