チュートリアル

Crop and Seek:高度な動画検索を体験

ミラン・キム

Crop and Seekは、Twelve Labs APIを使用した高度なビデオ検索機能のパワーを実証します。テキストおよび画像ベースの両方の検索と、独自の画像切り抜き機能を実装することにより、このアプリケーションは、関連するビデオコンテンツを発見するための柔軟で強力なツールを提供します。

Crop and Seekは、Twelve Labs APIを使用した高度なビデオ検索機能のパワーを実証します。テキストおよび画像ベースの両方の検索と、独自の画像切り抜き機能を実装することにより、このアプリケーションは、関連するビデオコンテンツを発見するための柔軟で強力なツールを提供します。

この記事の内容

No headings found on page

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

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

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

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

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

2024/10/02

9分

記事へのリンクをコピー

視覚的なコンテンツの探索方法を再定義する、高度なビデオ検索ツール「Crop and Seek」をご紹介します。テキスト検索でも画像検索でも、Crop and Seekは瞬時に結果を提供します。しかし、何よりも特徴的なのは画像クロッピング機能であり、リアルタイムで検索を絞り込むことができます。



画像ベースのクエリでは、デバイスから画像をアップロードするか、公開画像URLを入力します。プラットフォームは即座に画像に一致するビデオクリップを取得します。クエリ画像は左上に表示され、クリックして任意の部分を切り取ることができます。特定の詳細に焦点を当てたいですか?Crop and Seekを使用すると、選択した領域に基づいて新しい検索結果が表示され、さらに深く検索できます。

テキストでの検索をご希望ですか?テキスト検索フィールドにクエリを入力するだけで、Crop and Seekが関連するビデオコンテンツを提供します。 

⭐️ デモをチェックしてください!

前提条件

  • Twelve Labs Playgroundにアクセスしてサインアップし、APIキーを生成します。

  • インデックスを作成し、ビデオをインデックスにアップロードします。(❗️検索能力を最大限に引き出すために、「More options」にある「Logo」と「Text in Video」に必ずチェックを入れてください!)

  • このアプリはReactとNext.jsを使用して構築されています。 

  • このアプリのすべてのファイルを含むリポジトリは、GitHubで入手できます。

アプリケーションの構成

Crop and Seekアプリは、サーバーとクライアントのアーキテクチャで構築されています。 

サーバー側では、Next.jsがシームレスにルーティングを処理し、すべてのルートがapp/apiフォルダに整然と編成されています。6つのルートのうち、proxy-imageルートを除くすべてのルートがTwelve Labs APIと直接対話して、プラットフォームのコア機能を強化しています。

クライアント側では、アプリは複数のコンポーネントで構成されており、これについては後ほど詳しく説明します。大まかに言えば、Search Bar、Videos、SearchResultsという3つの主要コンポーネントがあります。Search Barは、画像ベースとテキストベースの両方のクエリを管理し、これらの機能を統一された検索体験に統合します。

画像検索はCrop and Seekの核となる機能であるため、このチュートリアルでは画像検索機能がどのように機能するのかを深く掘り下げ、その主要な要素と実用的なアプリケーションを案内します。

サーバー

Twelve Labs API リクエスト

Twelve Labs APIにリクエストを送信するルートは5つあります。

このチュートリアルでは、imgSearchルートの構築方法を説明します。ユーザーがアップロードできる画像には2つのタイプ(ファイルアップロードと公開URL)があるため、サーバーは両方を処理する必要があります。

imgSearch/route.js

import { NextRequest, NextResponse } from "next/server";
import axios from "axios";
import FormData from "form-data";


export const runtime = "nodejs";


export async function POST(request) {
 try {
   const formData = await request.formData();
   const apiKey = process.env.TWELVELABS_API_KEY;
   const indexId = process.env.TWELVELABS_INDEX_ID;


   if (!apiKey || !indexId) {
     return NextResponse.json(
       { error: "API key or Index ID is not set" },
       { status: 500 }
     );
   }


   const searchDataForm = new FormData();
   searchDataForm.append("search_options", "visual");
   searchDataForm.append("adjust_confidence_level", "0.55");
   searchDataForm.append("group_by", "clip");
   searchDataForm.append("threshold", "medium");
   searchDataForm.append("sort_option", "score");
   searchDataForm.append("page_limit", "12");
   searchDataForm.append("index_id", indexId);
   searchDataForm.append("query_media_type", "image");


   const imgQuery = formData.get("query");
   const imgFile = formData.get("file");


   if (imgQuery) {
     searchDataForm.append("query_media_url", imgQuery);
   } else if (imgFile && imgFile instanceof Blob) {
     const buffer = Buffer.from(await imgFile.arrayBuffer());
     searchDataForm.append("query_media_file", buffer, imgFile.name);
   } else {
     return NextResponse.json(
       { error: "No query or file provided" },
       { status: 400 }
     );
   }


   const formDataHeaders = searchDataForm.getHeaders();
   const url = "https://api.twelvelabs.io/v1.2/search-v2";


   const response = await axios.post(url, searchDataForm, {
     headers: {
       ...formDataHeaders,
       accept: "application/json",
       "x-api-key": `${apiKey}`,
     },
   });


   const imageResult = response.data;


   if (!imageResult || !imageResult.data) {
     return NextResponse.json(
       { error: "Error getting response from the API" },
       { status: 500 }
     );
   }


   const searchData = imageResult.data;
   const pageInfo = imageResult.page_info || {};


   return NextResponse.json({ pageInfo, searchData });
 } catch (error) {
   console.error("Error in POST handler:", error?.response?.data || error);
   const status = error?.response?.status || 500;
   const message = error?.response?.data?.message || error.message;


   return NextResponse.json({ error: message }, { status });
 }
}

プロキシ画像サーバー

なぜプロキシ画像サーバーが必要なのか疑問に思われるかもしれません。プロキシ画像サーバーは、私たちのアプリケーションにおいて不可欠な役割を果たしています。

  1. CORS処理: クロスオリジンリクエストを可能にし、クライアント側でさまざまなソースから画像を取得して表示させるために不可欠です。このプロキシサーバーがないと、CORS(Cross-Origin Resource Sharing)エラーにより、画像を取得して画像クロッピング領域に表示することができません。

  2. 画像ホスティング: プロキシサーバーは画像を一時的にホストし、アプリケーションが画像コンテンツを安全にロードできる信頼できるソースを作成します。

  3. セキュリティ: 画像リクエストを自社のサーバー経由でルーティングすることにより、セキュリティ層が追加され、外部の画像ソースがクライアントに直接さらされるのを防ぎます。

プロキシ画像サーバーの仕組みは次のとおりです。

  1. ユーザーが画像URLを入力すると、クライアントはこれをプロキシ画像サーバーに送信します。

  2. サーバーは元のソースから画像を取得します。

  3. その後、この画像を自身のドメインから配信し、CORS制限を実質的に回避します。

  4. これによりクライアントは、クロッピングやさらなる処理のために画像を安全にロードして表示することができます。

フロントエンド

大まかに説明すると、Crop and Seekのフロントエンドは非常にシンプルです。上部には固定のSearchBarが配置されています。ユーザーがテキストまたは画像クエリを送信しない場合、定義済みのインデックスからのビデオが表示されます。ユーザーがテキストまたは画像クエリを送信すると、検索結果が表示されます。

包括的な概要については、以下のコンポーネント設計図を参照してください。このチュートリアルでは画像検索機能に焦点を当て、最初の画像送信プロセス、およびクロッピングとその後の検索がどのように処理されるかについて詳しく説明します。

最初の画像検索の仕組み 

最初の画像検索は、`SearchByImageButtonAndModal` コンポーネントによって管理されます。ユーザーが「Search by image(画像で検索)」ボタンをクリックすると、画像ドロップゾーンと画像リンク入力フォームを含むダイアログが表示されます。

スタイリング用の要素があるためコードは複雑に見えるかもしれませんが、コアとなる機能は単純です。画像がファイルまたはURLとして送信されると、`handleImgSubmit` 関数によって処理されます。handleImgSubmit関数はimgQueryステートを設定し、これによってimgSearch useQueryフックがトリガーされます。このフックは次にfetchImageSearchResultsを呼び出し、画像データを使用してサーバーに検索リクエストを送信します。

ファイルを画像として受け取る 

react-dropzoneライブラリのuseDropzoneフックを使用して、ユーザーのデバイスから画像ファイルをアップロードするためのドラッグ&ドロップ領域を作成しています。 

SearchByImageButtonAndModal.js (52〜68行目)

** Configures the drag-and-drop area for image uploads, handling accepted and rejected files */
 const { getRootProps, getInputProps, isDragAccept } = useDropzone({
   // Specify the types of image files that are allowed
   accept: acceptedImageTypes,
   // Set the maximum file size for uploads (5MB)
   maxSize: MAX_IMAGE_SIZE,
   // Ensure only one file can be uploaded at a time
   multiple: false,
     onDragEnter: () => {
     setErrorCode(undefined);  // Clear any existing error messages 
   },
   onDropAccepted: (files) => {
     handleImgSubmit(files[0]); // Submit the file
     closeModal(); // Close the modal
   },
   // Handle the event when an invalid file is dropped
   onDropRejected: (fileRejections) => {
     // Get the error code for why the file was rejected
     const code = fileRejections[0]?.errors?.[0]?.code;
     if (code) setErrorCode(code);
   },
 });

URLを画像として受け取る

ユーザーの入力(画像URL)は、Inputフィールドを介してキャプチャされます。imageUrlFromInputステートは、入力値が変更されるたびに更新されます。 

SearchByImageButtonAndModal.js (187〜207行目)

<Input
         className="h-10 border-r-0"
         fullWidth
         placeholder="Drop an image address (not link address)"
         icon={<InsertLink className="text-grey-600" fontSize="small" />}
         value={imageUrlFromInput}
         onSelect={(e) => {
               e.stopPropagation();
             }}
         onChange={(e) => {
               setImageUrlFromInput(e.target.value);
             }}
         onClear={() => setImageUrlFromInput("")}
         type="text"
           />
      <Button
         type="button"
         appearance="primary"
         onClick={handleImageUrl}
         disabled={!imageUrlFromInput}

送信ボタンがクリックされると、handleImageUrl関数がURLをトリミングし、handleImgSubmit関数を呼び出します。 

SearchByImageButtonAndModal.js (70~88行目)

/** Validates the input URL and submits the image if valid */
 const handleImageUrl = () => {
   try {
     const trimmedUrl = imageUrlFromInput
       .trim()
       .replace(/(\.jpg|\.jpeg|\.png).*/i, "$1");
     const isImage =
       /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(trimmedUrl) ||
       /f=image|f=auto/.test(trimmedUrl);
     if (!isImage) {
       setErrorCode("invalid-url");
       return;
     }
     handleImgSubmit(trimmedUrl);
     closeModal();
   } catch (e) {
     setErrorCode("invalid-url");
   }
 };

送信された画像の処理

handleImgSubmitは、まず関連するステートをリセットし、ソースから画像名を抽出します。次に、新しい画像クエリを設定します。imgQueryが設定されると、imgSearch useQueryフックが有効になり、fetchImgSearchResultsが実行されます。 

page.js (28〜40行目)

/** Set image name and query src  */
 const handleImgSubmit = async (src) => {
    // Reset states
   setImgQuery(null);
   setUpdatedSearchData({ searchData: [], pageInfo: {} });
   setTextSearchSubmitted(false);


    // Extract image name from src (URL or File)	
   setImgName(typeof src === "string" ? src.split("/").pop() : src.name);
    // Set new image query
   setImgQuery(src);
 };

fetchImgSearchResultsは、上記の「サーバー」セクションで説明したサーバーの /api/imgSearch ルートにリクエストを送信します。 

SearchResults.js (27〜48行目)

/** Sends a request to the server to fetch image search results */
 const fetchImgSearchResults = async (imagePath) => {
   // Create a new FormData object to send data to the server
   const formData = new FormData();


   if (imagePath instanceof File) {
     // If the image is a File, append it to FormData with the key "file"
     formData.append("file", imagePath);
   } else {
     // If it's not a File, append it with the key "query"
     formData.append("query", imagePath);
   }


   // Send a POST request to the server's image search API
   const response = await fetch("/api/imgSearch", {
     method: "POST",
     body: formData,
   });


   if (!response.ok) {
     const errorData = await response.json();
     throw new Error(errorData.error || "Network response was not ok");
   }


   return response.json();
 };

画像のクロッピングとクロップされた画像検索の仕組み 

画像のクロッピングは、`ImageCropArea` コンポーネントによって管理されます。ユーザーが検索バーで選択した画像プレビューをクリックすると、React用の画像クロッピングツールである React Crop インターフェース内に画像を表示するダイアログが開きます。

ImageCropAreaでは、ReactCropが画像クロッピングに対するユーザーの操作を処理します。ユーザーが画像のクロッピングを完了して「Search(検索)」ボタンをクリックすると、onCropSearchClickがトリガーされます。

getCroppedImageは、キャンバスを使用してクロップされた画像を作成し、その後クロップされた画像をFileオブジェクトに変換します。次に画像クエリが設定され、imgSearch useQueryフックがトリガーされます。このフックはfetchImageSearchResultsを呼び出し、画像データを使用してサーバーに検索リクエストを送信します。

ImageCropArea.js (87〜112行目)

/** Handles the cropping of an image, converts the crop to a File, and updates state with the cropped image */
 const onCropSearchClick = async () => {
   if (completedCrop && imgRef.current) {
     try {
       // Step 1: Get the cropped image as a base64 string
       const base64Image = await getCroppedImage(
         imgRef.current,
         completedCrop
       );


       // Step 2: Convert the base64 string to a File object
       const croppedImageFile = await base64ToFile(
         base64Image,
         `${imgName}-cropped`
       );


       // Step 3: Update state and close modal 
       if (croppedImageFile) {
         // Update the image query with the new cropped image File
         setImgQuery(croppedImageFile);
         // Update the image name to reflect the cropped version
         setImgName(croppedImageFile.name);
         // Close the crop modal
         closeDisplayModal();
       }
     } catch (error) {
       console.error("Error processing image:", error);
     }
   } else {
     console.warn("No completed crop or imgRef.current is null");
   }
 };

結論

このチュートリアルでは、画像検索機能に焦点を当て、このアプリケーションを構築するための主要なコンポーネントを段階的に説明しました。画像のアップロード手法(ファイルとURLの両方)の扱い方、サーバー側での処理、そしてより洗練された検索を行うための画像クロッピング機能の実装方法についてカバーしました。

フルコードベースを探索し、機能を試し、このアプリケーションを自身のユースケースに合わせてどのように拡張または適応できるか検討してみることをお勧めします。ハッピーコーディング!

FAQ

Q: 公開URLを使用して画像をアップロードしようとすると、「入力されたURLは有効な画像を指していません」というメッセージが表示されます。

A: 画像のURLをコピーするときは、「リンクのアドレス」ではなく「画像のアドレス」をコピーしてください。

Q: 検索用にアップロードできる画像ファイルの形式は何ですか?

A: Crop and Seek(Twelve Labs Image Search)は、JPG、JPEG、PNGなどの画像フォーマットをサポートしています。アップロード可能な最大ファイルサイズは5MBです。画像の解像度は少なくとも 378 x 378 px 以上である必要があります。 

Q: 画像検索とテキスト検索を組み合わせることはできますか?

A: Crop and Seekは、一度に画像検索またはテキスト検索のいずれか一方のみをサポートしています。ただし、画像クロッピング機能を使用してビジュアル検索を絞り込むことができます。これは、画像内の特定の要素に焦点を当てる強力な方法となります。

Q: アップロードした画像はサーバーに保存されますか?

A: いいえ、アップロードされた画像が永続的に保存されることはありません。これらは検索リクエストの処理のために一時的に使用されるだけで、検索完了後に保持されることはありません。

視覚的なコンテンツの探索方法を再定義する、高度なビデオ検索ツール「Crop and Seek」をご紹介します。テキスト検索でも画像検索でも、Crop and Seekは瞬時に結果を提供します。しかし、何よりも特徴的なのは画像クロッピング機能であり、リアルタイムで検索を絞り込むことができます。



画像ベースのクエリでは、デバイスから画像をアップロードするか、公開画像URLを入力します。プラットフォームは即座に画像に一致するビデオクリップを取得します。クエリ画像は左上に表示され、クリックして任意の部分を切り取ることができます。特定の詳細に焦点を当てたいですか?Crop and Seekを使用すると、選択した領域に基づいて新しい検索結果が表示され、さらに深く検索できます。

テキストでの検索をご希望ですか?テキスト検索フィールドにクエリを入力するだけで、Crop and Seekが関連するビデオコンテンツを提供します。 

⭐️ デモをチェックしてください!

前提条件

  • Twelve Labs Playgroundにアクセスしてサインアップし、APIキーを生成します。

  • インデックスを作成し、ビデオをインデックスにアップロードします。(❗️検索能力を最大限に引き出すために、「More options」にある「Logo」と「Text in Video」に必ずチェックを入れてください!)

  • このアプリはReactとNext.jsを使用して構築されています。 

  • このアプリのすべてのファイルを含むリポジトリは、GitHubで入手できます。

アプリケーションの構成

Crop and Seekアプリは、サーバーとクライアントのアーキテクチャで構築されています。 

サーバー側では、Next.jsがシームレスにルーティングを処理し、すべてのルートがapp/apiフォルダに整然と編成されています。6つのルートのうち、proxy-imageルートを除くすべてのルートがTwelve Labs APIと直接対話して、プラットフォームのコア機能を強化しています。

クライアント側では、アプリは複数のコンポーネントで構成されており、これについては後ほど詳しく説明します。大まかに言えば、Search Bar、Videos、SearchResultsという3つの主要コンポーネントがあります。Search Barは、画像ベースとテキストベースの両方のクエリを管理し、これらの機能を統一された検索体験に統合します。

画像検索はCrop and Seekの核となる機能であるため、このチュートリアルでは画像検索機能がどのように機能するのかを深く掘り下げ、その主要な要素と実用的なアプリケーションを案内します。

サーバー

Twelve Labs API リクエスト

Twelve Labs APIにリクエストを送信するルートは5つあります。

このチュートリアルでは、imgSearchルートの構築方法を説明します。ユーザーがアップロードできる画像には2つのタイプ(ファイルアップロードと公開URL)があるため、サーバーは両方を処理する必要があります。

imgSearch/route.js

import { NextRequest, NextResponse } from "next/server";
import axios from "axios";
import FormData from "form-data";


export const runtime = "nodejs";


export async function POST(request) {
 try {
   const formData = await request.formData();
   const apiKey = process.env.TWELVELABS_API_KEY;
   const indexId = process.env.TWELVELABS_INDEX_ID;


   if (!apiKey || !indexId) {
     return NextResponse.json(
       { error: "API key or Index ID is not set" },
       { status: 500 }
     );
   }


   const searchDataForm = new FormData();
   searchDataForm.append("search_options", "visual");
   searchDataForm.append("adjust_confidence_level", "0.55");
   searchDataForm.append("group_by", "clip");
   searchDataForm.append("threshold", "medium");
   searchDataForm.append("sort_option", "score");
   searchDataForm.append("page_limit", "12");
   searchDataForm.append("index_id", indexId);
   searchDataForm.append("query_media_type", "image");


   const imgQuery = formData.get("query");
   const imgFile = formData.get("file");


   if (imgQuery) {
     searchDataForm.append("query_media_url", imgQuery);
   } else if (imgFile && imgFile instanceof Blob) {
     const buffer = Buffer.from(await imgFile.arrayBuffer());
     searchDataForm.append("query_media_file", buffer, imgFile.name);
   } else {
     return NextResponse.json(
       { error: "No query or file provided" },
       { status: 400 }
     );
   }


   const formDataHeaders = searchDataForm.getHeaders();
   const url = "https://api.twelvelabs.io/v1.2/search-v2";


   const response = await axios.post(url, searchDataForm, {
     headers: {
       ...formDataHeaders,
       accept: "application/json",
       "x-api-key": `${apiKey}`,
     },
   });


   const imageResult = response.data;


   if (!imageResult || !imageResult.data) {
     return NextResponse.json(
       { error: "Error getting response from the API" },
       { status: 500 }
     );
   }


   const searchData = imageResult.data;
   const pageInfo = imageResult.page_info || {};


   return NextResponse.json({ pageInfo, searchData });
 } catch (error) {
   console.error("Error in POST handler:", error?.response?.data || error);
   const status = error?.response?.status || 500;
   const message = error?.response?.data?.message || error.message;


   return NextResponse.json({ error: message }, { status });
 }
}

プロキシ画像サーバー

なぜプロキシ画像サーバーが必要なのか疑問に思われるかもしれません。プロキシ画像サーバーは、私たちのアプリケーションにおいて不可欠な役割を果たしています。

  1. CORS処理: クロスオリジンリクエストを可能にし、クライアント側でさまざまなソースから画像を取得して表示させるために不可欠です。このプロキシサーバーがないと、CORS(Cross-Origin Resource Sharing)エラーにより、画像を取得して画像クロッピング領域に表示することができません。

  2. 画像ホスティング: プロキシサーバーは画像を一時的にホストし、アプリケーションが画像コンテンツを安全にロードできる信頼できるソースを作成します。

  3. セキュリティ: 画像リクエストを自社のサーバー経由でルーティングすることにより、セキュリティ層が追加され、外部の画像ソースがクライアントに直接さらされるのを防ぎます。

プロキシ画像サーバーの仕組みは次のとおりです。

  1. ユーザーが画像URLを入力すると、クライアントはこれをプロキシ画像サーバーに送信します。

  2. サーバーは元のソースから画像を取得します。

  3. その後、この画像を自身のドメインから配信し、CORS制限を実質的に回避します。

  4. これによりクライアントは、クロッピングやさらなる処理のために画像を安全にロードして表示することができます。

フロントエンド

大まかに説明すると、Crop and Seekのフロントエンドは非常にシンプルです。上部には固定のSearchBarが配置されています。ユーザーがテキストまたは画像クエリを送信しない場合、定義済みのインデックスからのビデオが表示されます。ユーザーがテキストまたは画像クエリを送信すると、検索結果が表示されます。

包括的な概要については、以下のコンポーネント設計図を参照してください。このチュートリアルでは画像検索機能に焦点を当て、最初の画像送信プロセス、およびクロッピングとその後の検索がどのように処理されるかについて詳しく説明します。

最初の画像検索の仕組み 

最初の画像検索は、`SearchByImageButtonAndModal` コンポーネントによって管理されます。ユーザーが「Search by image(画像で検索)」ボタンをクリックすると、画像ドロップゾーンと画像リンク入力フォームを含むダイアログが表示されます。

スタイリング用の要素があるためコードは複雑に見えるかもしれませんが、コアとなる機能は単純です。画像がファイルまたはURLとして送信されると、`handleImgSubmit` 関数によって処理されます。handleImgSubmit関数はimgQueryステートを設定し、これによってimgSearch useQueryフックがトリガーされます。このフックは次にfetchImageSearchResultsを呼び出し、画像データを使用してサーバーに検索リクエストを送信します。

ファイルを画像として受け取る 

react-dropzoneライブラリのuseDropzoneフックを使用して、ユーザーのデバイスから画像ファイルをアップロードするためのドラッグ&ドロップ領域を作成しています。 

SearchByImageButtonAndModal.js (52〜68行目)

** Configures the drag-and-drop area for image uploads, handling accepted and rejected files */
 const { getRootProps, getInputProps, isDragAccept } = useDropzone({
   // Specify the types of image files that are allowed
   accept: acceptedImageTypes,
   // Set the maximum file size for uploads (5MB)
   maxSize: MAX_IMAGE_SIZE,
   // Ensure only one file can be uploaded at a time
   multiple: false,
     onDragEnter: () => {
     setErrorCode(undefined);  // Clear any existing error messages 
   },
   onDropAccepted: (files) => {
     handleImgSubmit(files[0]); // Submit the file
     closeModal(); // Close the modal
   },
   // Handle the event when an invalid file is dropped
   onDropRejected: (fileRejections) => {
     // Get the error code for why the file was rejected
     const code = fileRejections[0]?.errors?.[0]?.code;
     if (code) setErrorCode(code);
   },
 });

URLを画像として受け取る

ユーザーの入力(画像URL)は、Inputフィールドを介してキャプチャされます。imageUrlFromInputステートは、入力値が変更されるたびに更新されます。 

SearchByImageButtonAndModal.js (187〜207行目)

<Input
         className="h-10 border-r-0"
         fullWidth
         placeholder="Drop an image address (not link address)"
         icon={<InsertLink className="text-grey-600" fontSize="small" />}
         value={imageUrlFromInput}
         onSelect={(e) => {
               e.stopPropagation();
             }}
         onChange={(e) => {
               setImageUrlFromInput(e.target.value);
             }}
         onClear={() => setImageUrlFromInput("")}
         type="text"
           />
      <Button
         type="button"
         appearance="primary"
         onClick={handleImageUrl}
         disabled={!imageUrlFromInput}

送信ボタンがクリックされると、handleImageUrl関数がURLをトリミングし、handleImgSubmit関数を呼び出します。 

SearchByImageButtonAndModal.js (70~88行目)

/** Validates the input URL and submits the image if valid */
 const handleImageUrl = () => {
   try {
     const trimmedUrl = imageUrlFromInput
       .trim()
       .replace(/(\.jpg|\.jpeg|\.png).*/i, "$1");
     const isImage =
       /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(trimmedUrl) ||
       /f=image|f=auto/.test(trimmedUrl);
     if (!isImage) {
       setErrorCode("invalid-url");
       return;
     }
     handleImgSubmit(trimmedUrl);
     closeModal();
   } catch (e) {
     setErrorCode("invalid-url");
   }
 };

送信された画像の処理

handleImgSubmitは、まず関連するステートをリセットし、ソースから画像名を抽出します。次に、新しい画像クエリを設定します。imgQueryが設定されると、imgSearch useQueryフックが有効になり、fetchImgSearchResultsが実行されます。 

page.js (28〜40行目)

/** Set image name and query src  */
 const handleImgSubmit = async (src) => {
    // Reset states
   setImgQuery(null);
   setUpdatedSearchData({ searchData: [], pageInfo: {} });
   setTextSearchSubmitted(false);


    // Extract image name from src (URL or File)	
   setImgName(typeof src === "string" ? src.split("/").pop() : src.name);
    // Set new image query
   setImgQuery(src);
 };

fetchImgSearchResultsは、上記の「サーバー」セクションで説明したサーバーの /api/imgSearch ルートにリクエストを送信します。 

SearchResults.js (27〜48行目)

/** Sends a request to the server to fetch image search results */
 const fetchImgSearchResults = async (imagePath) => {
   // Create a new FormData object to send data to the server
   const formData = new FormData();


   if (imagePath instanceof File) {
     // If the image is a File, append it to FormData with the key "file"
     formData.append("file", imagePath);
   } else {
     // If it's not a File, append it with the key "query"
     formData.append("query", imagePath);
   }


   // Send a POST request to the server's image search API
   const response = await fetch("/api/imgSearch", {
     method: "POST",
     body: formData,
   });


   if (!response.ok) {
     const errorData = await response.json();
     throw new Error(errorData.error || "Network response was not ok");
   }


   return response.json();
 };

画像のクロッピングとクロップされた画像検索の仕組み 

画像のクロッピングは、`ImageCropArea` コンポーネントによって管理されます。ユーザーが検索バーで選択した画像プレビューをクリックすると、React用の画像クロッピングツールである React Crop インターフェース内に画像を表示するダイアログが開きます。

ImageCropAreaでは、ReactCropが画像クロッピングに対するユーザーの操作を処理します。ユーザーが画像のクロッピングを完了して「Search(検索)」ボタンをクリックすると、onCropSearchClickがトリガーされます。

getCroppedImageは、キャンバスを使用してクロップされた画像を作成し、その後クロップされた画像をFileオブジェクトに変換します。次に画像クエリが設定され、imgSearch useQueryフックがトリガーされます。このフックはfetchImageSearchResultsを呼び出し、画像データを使用してサーバーに検索リクエストを送信します。

ImageCropArea.js (87〜112行目)

/** Handles the cropping of an image, converts the crop to a File, and updates state with the cropped image */
 const onCropSearchClick = async () => {
   if (completedCrop && imgRef.current) {
     try {
       // Step 1: Get the cropped image as a base64 string
       const base64Image = await getCroppedImage(
         imgRef.current,
         completedCrop
       );


       // Step 2: Convert the base64 string to a File object
       const croppedImageFile = await base64ToFile(
         base64Image,
         `${imgName}-cropped`
       );


       // Step 3: Update state and close modal 
       if (croppedImageFile) {
         // Update the image query with the new cropped image File
         setImgQuery(croppedImageFile);
         // Update the image name to reflect the cropped version
         setImgName(croppedImageFile.name);
         // Close the crop modal
         closeDisplayModal();
       }
     } catch (error) {
       console.error("Error processing image:", error);
     }
   } else {
     console.warn("No completed crop or imgRef.current is null");
   }
 };

結論

このチュートリアルでは、画像検索機能に焦点を当て、このアプリケーションを構築するための主要なコンポーネントを段階的に説明しました。画像のアップロード手法(ファイルとURLの両方)の扱い方、サーバー側での処理、そしてより洗練された検索を行うための画像クロッピング機能の実装方法についてカバーしました。

フルコードベースを探索し、機能を試し、このアプリケーションを自身のユースケースに合わせてどのように拡張または適応できるか検討してみることをお勧めします。ハッピーコーディング!

FAQ

Q: 公開URLを使用して画像をアップロードしようとすると、「入力されたURLは有効な画像を指していません」というメッセージが表示されます。

A: 画像のURLをコピーするときは、「リンクのアドレス」ではなく「画像のアドレス」をコピーしてください。

Q: 検索用にアップロードできる画像ファイルの形式は何ですか?

A: Crop and Seek(Twelve Labs Image Search)は、JPG、JPEG、PNGなどの画像フォーマットをサポートしています。アップロード可能な最大ファイルサイズは5MBです。画像の解像度は少なくとも 378 x 378 px 以上である必要があります。 

Q: 画像検索とテキスト検索を組み合わせることはできますか?

A: Crop and Seekは、一度に画像検索またはテキスト検索のいずれか一方のみをサポートしています。ただし、画像クロッピング機能を使用してビジュアル検索を絞り込むことができます。これは、画像内の特定の要素に焦点を当てる強力な方法となります。

Q: アップロードした画像はサーバーに保存されますか?

A: いいえ、アップロードされた画像が永続的に保存されることはありません。これらは検索リクエストの処理のために一時的に使用されるだけで、検索完了後に保持されることはありません。

視覚的なコンテンツの探索方法を再定義する、高度なビデオ検索ツール「Crop and Seek」をご紹介します。テキスト検索でも画像検索でも、Crop and Seekは瞬時に結果を提供します。しかし、何よりも特徴的なのは画像クロッピング機能であり、リアルタイムで検索を絞り込むことができます。



画像ベースのクエリでは、デバイスから画像をアップロードするか、公開画像URLを入力します。プラットフォームは即座に画像に一致するビデオクリップを取得します。クエリ画像は左上に表示され、クリックして任意の部分を切り取ることができます。特定の詳細に焦点を当てたいですか?Crop and Seekを使用すると、選択した領域に基づいて新しい検索結果が表示され、さらに深く検索できます。

テキストでの検索をご希望ですか?テキスト検索フィールドにクエリを入力するだけで、Crop and Seekが関連するビデオコンテンツを提供します。 

⭐️ デモをチェックしてください!

前提条件

  • Twelve Labs Playgroundにアクセスしてサインアップし、APIキーを生成します。

  • インデックスを作成し、ビデオをインデックスにアップロードします。(❗️検索能力を最大限に引き出すために、「More options」にある「Logo」と「Text in Video」に必ずチェックを入れてください!)

  • このアプリはReactとNext.jsを使用して構築されています。 

  • このアプリのすべてのファイルを含むリポジトリは、GitHubで入手できます。

アプリケーションの構成

Crop and Seekアプリは、サーバーとクライアントのアーキテクチャで構築されています。 

サーバー側では、Next.jsがシームレスにルーティングを処理し、すべてのルートがapp/apiフォルダに整然と編成されています。6つのルートのうち、proxy-imageルートを除くすべてのルートがTwelve Labs APIと直接対話して、プラットフォームのコア機能を強化しています。

クライアント側では、アプリは複数のコンポーネントで構成されており、これについては後ほど詳しく説明します。大まかに言えば、Search Bar、Videos、SearchResultsという3つの主要コンポーネントがあります。Search Barは、画像ベースとテキストベースの両方のクエリを管理し、これらの機能を統一された検索体験に統合します。

画像検索はCrop and Seekの核となる機能であるため、このチュートリアルでは画像検索機能がどのように機能するのかを深く掘り下げ、その主要な要素と実用的なアプリケーションを案内します。

サーバー

Twelve Labs API リクエスト

Twelve Labs APIにリクエストを送信するルートは5つあります。

このチュートリアルでは、imgSearchルートの構築方法を説明します。ユーザーがアップロードできる画像には2つのタイプ(ファイルアップロードと公開URL)があるため、サーバーは両方を処理する必要があります。

imgSearch/route.js

import { NextRequest, NextResponse } from "next/server";
import axios from "axios";
import FormData from "form-data";


export const runtime = "nodejs";


export async function POST(request) {
 try {
   const formData = await request.formData();
   const apiKey = process.env.TWELVELABS_API_KEY;
   const indexId = process.env.TWELVELABS_INDEX_ID;


   if (!apiKey || !indexId) {
     return NextResponse.json(
       { error: "API key or Index ID is not set" },
       { status: 500 }
     );
   }


   const searchDataForm = new FormData();
   searchDataForm.append("search_options", "visual");
   searchDataForm.append("adjust_confidence_level", "0.55");
   searchDataForm.append("group_by", "clip");
   searchDataForm.append("threshold", "medium");
   searchDataForm.append("sort_option", "score");
   searchDataForm.append("page_limit", "12");
   searchDataForm.append("index_id", indexId);
   searchDataForm.append("query_media_type", "image");


   const imgQuery = formData.get("query");
   const imgFile = formData.get("file");


   if (imgQuery) {
     searchDataForm.append("query_media_url", imgQuery);
   } else if (imgFile && imgFile instanceof Blob) {
     const buffer = Buffer.from(await imgFile.arrayBuffer());
     searchDataForm.append("query_media_file", buffer, imgFile.name);
   } else {
     return NextResponse.json(
       { error: "No query or file provided" },
       { status: 400 }
     );
   }


   const formDataHeaders = searchDataForm.getHeaders();
   const url = "https://api.twelvelabs.io/v1.2/search-v2";


   const response = await axios.post(url, searchDataForm, {
     headers: {
       ...formDataHeaders,
       accept: "application/json",
       "x-api-key": `${apiKey}`,
     },
   });


   const imageResult = response.data;


   if (!imageResult || !imageResult.data) {
     return NextResponse.json(
       { error: "Error getting response from the API" },
       { status: 500 }
     );
   }


   const searchData = imageResult.data;
   const pageInfo = imageResult.page_info || {};


   return NextResponse.json({ pageInfo, searchData });
 } catch (error) {
   console.error("Error in POST handler:", error?.response?.data || error);
   const status = error?.response?.status || 500;
   const message = error?.response?.data?.message || error.message;


   return NextResponse.json({ error: message }, { status });
 }
}

プロキシ画像サーバー

なぜプロキシ画像サーバーが必要なのか疑問に思われるかもしれません。プロキシ画像サーバーは、私たちのアプリケーションにおいて不可欠な役割を果たしています。

  1. CORS処理: クロスオリジンリクエストを可能にし、クライアント側でさまざまなソースから画像を取得して表示させるために不可欠です。このプロキシサーバーがないと、CORS(Cross-Origin Resource Sharing)エラーにより、画像を取得して画像クロッピング領域に表示することができません。

  2. 画像ホスティング: プロキシサーバーは画像を一時的にホストし、アプリケーションが画像コンテンツを安全にロードできる信頼できるソースを作成します。

  3. セキュリティ: 画像リクエストを自社のサーバー経由でルーティングすることにより、セキュリティ層が追加され、外部の画像ソースがクライアントに直接さらされるのを防ぎます。

プロキシ画像サーバーの仕組みは次のとおりです。

  1. ユーザーが画像URLを入力すると、クライアントはこれをプロキシ画像サーバーに送信します。

  2. サーバーは元のソースから画像を取得します。

  3. その後、この画像を自身のドメインから配信し、CORS制限を実質的に回避します。

  4. これによりクライアントは、クロッピングやさらなる処理のために画像を安全にロードして表示することができます。

フロントエンド

大まかに説明すると、Crop and Seekのフロントエンドは非常にシンプルです。上部には固定のSearchBarが配置されています。ユーザーがテキストまたは画像クエリを送信しない場合、定義済みのインデックスからのビデオが表示されます。ユーザーがテキストまたは画像クエリを送信すると、検索結果が表示されます。

包括的な概要については、以下のコンポーネント設計図を参照してください。このチュートリアルでは画像検索機能に焦点を当て、最初の画像送信プロセス、およびクロッピングとその後の検索がどのように処理されるかについて詳しく説明します。

最初の画像検索の仕組み 

最初の画像検索は、`SearchByImageButtonAndModal` コンポーネントによって管理されます。ユーザーが「Search by image(画像で検索)」ボタンをクリックすると、画像ドロップゾーンと画像リンク入力フォームを含むダイアログが表示されます。

スタイリング用の要素があるためコードは複雑に見えるかもしれませんが、コアとなる機能は単純です。画像がファイルまたはURLとして送信されると、`handleImgSubmit` 関数によって処理されます。handleImgSubmit関数はimgQueryステートを設定し、これによってimgSearch useQueryフックがトリガーされます。このフックは次にfetchImageSearchResultsを呼び出し、画像データを使用してサーバーに検索リクエストを送信します。

ファイルを画像として受け取る 

react-dropzoneライブラリのuseDropzoneフックを使用して、ユーザーのデバイスから画像ファイルをアップロードするためのドラッグ&ドロップ領域を作成しています。 

SearchByImageButtonAndModal.js (52〜68行目)

** Configures the drag-and-drop area for image uploads, handling accepted and rejected files */
 const { getRootProps, getInputProps, isDragAccept } = useDropzone({
   // Specify the types of image files that are allowed
   accept: acceptedImageTypes,
   // Set the maximum file size for uploads (5MB)
   maxSize: MAX_IMAGE_SIZE,
   // Ensure only one file can be uploaded at a time
   multiple: false,
     onDragEnter: () => {
     setErrorCode(undefined);  // Clear any existing error messages 
   },
   onDropAccepted: (files) => {
     handleImgSubmit(files[0]); // Submit the file
     closeModal(); // Close the modal
   },
   // Handle the event when an invalid file is dropped
   onDropRejected: (fileRejections) => {
     // Get the error code for why the file was rejected
     const code = fileRejections[0]?.errors?.[0]?.code;
     if (code) setErrorCode(code);
   },
 });

URLを画像として受け取る

ユーザーの入力(画像URL)は、Inputフィールドを介してキャプチャされます。imageUrlFromInputステートは、入力値が変更されるたびに更新されます。 

SearchByImageButtonAndModal.js (187〜207行目)

<Input
         className="h-10 border-r-0"
         fullWidth
         placeholder="Drop an image address (not link address)"
         icon={<InsertLink className="text-grey-600" fontSize="small" />}
         value={imageUrlFromInput}
         onSelect={(e) => {
               e.stopPropagation();
             }}
         onChange={(e) => {
               setImageUrlFromInput(e.target.value);
             }}
         onClear={() => setImageUrlFromInput("")}
         type="text"
           />
      <Button
         type="button"
         appearance="primary"
         onClick={handleImageUrl}
         disabled={!imageUrlFromInput}

送信ボタンがクリックされると、handleImageUrl関数がURLをトリミングし、handleImgSubmit関数を呼び出します。 

SearchByImageButtonAndModal.js (70~88行目)

/** Validates the input URL and submits the image if valid */
 const handleImageUrl = () => {
   try {
     const trimmedUrl = imageUrlFromInput
       .trim()
       .replace(/(\.jpg|\.jpeg|\.png).*/i, "$1");
     const isImage =
       /\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(trimmedUrl) ||
       /f=image|f=auto/.test(trimmedUrl);
     if (!isImage) {
       setErrorCode("invalid-url");
       return;
     }
     handleImgSubmit(trimmedUrl);
     closeModal();
   } catch (e) {
     setErrorCode("invalid-url");
   }
 };

送信された画像の処理

handleImgSubmitは、まず関連するステートをリセットし、ソースから画像名を抽出します。次に、新しい画像クエリを設定します。imgQueryが設定されると、imgSearch useQueryフックが有効になり、fetchImgSearchResultsが実行されます。 

page.js (28〜40行目)

/** Set image name and query src  */
 const handleImgSubmit = async (src) => {
    // Reset states
   setImgQuery(null);
   setUpdatedSearchData({ searchData: [], pageInfo: {} });
   setTextSearchSubmitted(false);


    // Extract image name from src (URL or File)	
   setImgName(typeof src === "string" ? src.split("/").pop() : src.name);
    // Set new image query
   setImgQuery(src);
 };

fetchImgSearchResultsは、上記の「サーバー」セクションで説明したサーバーの /api/imgSearch ルートにリクエストを送信します。 

SearchResults.js (27〜48行目)

/** Sends a request to the server to fetch image search results */
 const fetchImgSearchResults = async (imagePath) => {
   // Create a new FormData object to send data to the server
   const formData = new FormData();


   if (imagePath instanceof File) {
     // If the image is a File, append it to FormData with the key "file"
     formData.append("file", imagePath);
   } else {
     // If it's not a File, append it with the key "query"
     formData.append("query", imagePath);
   }


   // Send a POST request to the server's image search API
   const response = await fetch("/api/imgSearch", {
     method: "POST",
     body: formData,
   });


   if (!response.ok) {
     const errorData = await response.json();
     throw new Error(errorData.error || "Network response was not ok");
   }


   return response.json();
 };

画像のクロッピングとクロップされた画像検索の仕組み 

画像のクロッピングは、`ImageCropArea` コンポーネントによって管理されます。ユーザーが検索バーで選択した画像プレビューをクリックすると、React用の画像クロッピングツールである React Crop インターフェース内に画像を表示するダイアログが開きます。

ImageCropAreaでは、ReactCropが画像クロッピングに対するユーザーの操作を処理します。ユーザーが画像のクロッピングを完了して「Search(検索)」ボタンをクリックすると、onCropSearchClickがトリガーされます。

getCroppedImageは、キャンバスを使用してクロップされた画像を作成し、その後クロップされた画像をFileオブジェクトに変換します。次に画像クエリが設定され、imgSearch useQueryフックがトリガーされます。このフックはfetchImageSearchResultsを呼び出し、画像データを使用してサーバーに検索リクエストを送信します。

ImageCropArea.js (87〜112行目)

/** Handles the cropping of an image, converts the crop to a File, and updates state with the cropped image */
 const onCropSearchClick = async () => {
   if (completedCrop && imgRef.current) {
     try {
       // Step 1: Get the cropped image as a base64 string
       const base64Image = await getCroppedImage(
         imgRef.current,
         completedCrop
       );


       // Step 2: Convert the base64 string to a File object
       const croppedImageFile = await base64ToFile(
         base64Image,
         `${imgName}-cropped`
       );


       // Step 3: Update state and close modal 
       if (croppedImageFile) {
         // Update the image query with the new cropped image File
         setImgQuery(croppedImageFile);
         // Update the image name to reflect the cropped version
         setImgName(croppedImageFile.name);
         // Close the crop modal
         closeDisplayModal();
       }
     } catch (error) {
       console.error("Error processing image:", error);
     }
   } else {
     console.warn("No completed crop or imgRef.current is null");
   }
 };

結論

このチュートリアルでは、画像検索機能に焦点を当て、このアプリケーションを構築するための主要なコンポーネントを段階的に説明しました。画像のアップロード手法(ファイルとURLの両方)の扱い方、サーバー側での処理、そしてより洗練された検索を行うための画像クロッピング機能の実装方法についてカバーしました。

フルコードベースを探索し、機能を試し、このアプリケーションを自身のユースケースに合わせてどのように拡張または適応できるか検討してみることをお勧めします。ハッピーコーディング!

FAQ

Q: 公開URLを使用して画像をアップロードしようとすると、「入力されたURLは有効な画像を指していません」というメッセージが表示されます。

A: 画像のURLをコピーするときは、「リンクのアドレス」ではなく「画像のアドレス」をコピーしてください。

Q: 検索用にアップロードできる画像ファイルの形式は何ですか?

A: Crop and Seek(Twelve Labs Image Search)は、JPG、JPEG、PNGなどの画像フォーマットをサポートしています。アップロード可能な最大ファイルサイズは5MBです。画像の解像度は少なくとも 378 x 378 px 以上である必要があります。 

Q: 画像検索とテキスト検索を組み合わせることはできますか?

A: Crop and Seekは、一度に画像検索またはテキスト検索のいずれか一方のみをサポートしています。ただし、画像クロッピング機能を使用してビジュアル検索を絞り込むことができます。これは、画像内の特定の要素に焦点を当てる強力な方法となります。

Q: アップロードした画像はサーバーに保存されますか?

A: いいえ、アップロードされた画像が永続的に保存されることはありません。これらは検索リクエストの処理のために一時的に使用されるだけで、検索完了後に保持されることはありません。