4
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Gemini Embedding 2 で8種のカレー画像の類似度を測ってみた

4
Last updated at Posted at 2026-03-22

はじめに

svg_01_introduction.png

2026年3月10日、Google が 「Gemini Embedding 2」 を Public Preview として公開しました。
テキスト・画像・動画・音声・PDF を 「ひとつの embedding 空間」 にマッピングできる、Google 初のネイティブ・マルチモーダル embedding モデルです。

「画像だけでどれくらい使えるのか?」を確かめるために、8種類のカレー料理の画像を embedding して cosine 類似度で比較し、さらに Gemini 2.5 Flash に「なぜ似ているのか」を説明させる、というパイプラインを組んでみました。


使った画像

画像はすべて AI(nanobanana)で生成したものです。構図を似せたうえで差分を検出する実験のために作成しました。

ファイル名 料理
butter_chicken_naan.jpg バターチキンカレー+ナン
butter_chicken_rice.jpg バターチキンカレー+ライス
dry_curry.jpg ドライカレー(目玉焼き付き)
hayashi_style.jpg ハヤシライス風
katsu_curry.jpg カツカレー
keema_curry.jpg キーマカレー
soup_curry.jpg スープカレー
vegetable_curry.jpg 野菜カレー

「カレー」とひとくちに言っても、インド系、日本式、スープ系と見た目はかなり異なります。embedding モデルはこの違いをどこまで捉えるのか、というのが今回の実験の動機です。

画像例(クリックで展開)

butter_chicken_nun..jpg
butter_chicken_rice.jpg
dry_curry.jpg
hayashi_style.jpg
katsu_curry.jpg
keema_curry.jpg
soup_curry.jpg
vegetable_curry.jpg


手法の概要

svg_02_method.png

やっていることは4ステップです。

  1. 画像を embedding に変換
    gemini-embedding-2-preview に画像バイナリを渡し、約3000次元(現時点では3072次元)のベクトルを取得
  2. cosine 類似度で全ペアを比較
    8×8 の類似度行列を作成
  3. ヒートマップで可視化
    matplotlib で一覧表示
  4. LLM で類似理由を言語化
    最も似ている画像ペアを gemini-2.5-flash に渡し、「なぜ似ているか」を箇条書きで説明

embedding の取得と LLM での説明は Gemini API の Free Tier で試しました。


環境セットアップ

Google Colab を前提にしています。

!pip install -q google-genai pillow matplotlib numpy

API キーは Colab のシークレットに GOOGLE_API_KEY として登録してください。ローカルで実行する場合は os.environ["GOOGLE_API_KEY"] から取得するように書き換えればOKです。

from google.colab import userdata
from google import genai
from google.genai import types

client = genai.Client(api_key=userdata.get("GOOGLE_API_KEY"))

実装の流れ

svg_06_architecture.png

1. 画像の embedding 取得

import os
import numpy as np

def guess_mime_type(image_path: str) -> str:
    ext = os.path.splitext(image_path)[1].lower()
    if ext in [".jpg", ".jpeg"]:
        return "image/jpeg"
    elif ext == ".png":
        return "image/png"
    elif ext == ".webp":
        return "image/webp"
    else:
        raise ValueError(f"Unsupported image type: {ext}")

def embed_image(image_path: str) -> np.ndarray:
    with open(image_path, "rb") as f:
        image_bytes = f.read()

    result = client.models.embed_content(
        model="gemini-embedding-2-preview",
        contents=[
            types.Part.from_bytes(
                data=image_bytes,
                mime_type=guess_mime_type(image_path),
            )
        ]
    )

    return np.array(result.embeddings[0].values, dtype=np.float32)

embed_contentPart.from_bytes で画像バイナリを渡すだけです。テキストと同じインターフェースでマルチモーダル embedding が取れるのは便利ですね。

2. cosine 類似度と類似度行列

def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    denom = np.linalg.norm(a) * np.linalg.norm(b)
    if denom == 0:
        return 0.0
    return float(np.dot(a, b) / denom)

def build_similarity_matrix(index):
    n = len(index)
    sim_matrix = np.zeros((n, n), dtype=np.float32)
    for i in range(n):
        for j in range(n):
            sim_matrix[i, j] = cosine_similarity(
                index[i]["embedding"],
                index[j]["embedding"]
            )
    return sim_matrix

3. LLM で類似理由を説明

def explain_similarity_with_llm(query_path: str, target_path: str, score: float) -> str:
    with open(query_path, "rb") as f:
        query_bytes = f.read()
    with open(target_path, "rb") as f:
        target_bytes = f.read()

    prompt = f"""
次の2枚の料理画像について、日本語で簡潔に説明してください。

出力ルール:
- 3点だけ箇条書き
- 1行目に「類似度: {score:.4f}」
- 「似ている点」を中心に書く
- 推測しすぎない
- 料理名の断定より、見た目・構図・質感・色味・具材の配置に注目する
"""

    response = client.models.generate_content(
        model="gemini-2.5-flash",
        contents=[
            prompt,
            types.Part.from_bytes(
                data=query_bytes,
                mime_type=guess_mime_type(query_path),
            ),
            types.Part.from_bytes(
                data=target_bytes,
                mime_type=guess_mime_type(target_path),
            ),
        ],
    )

    return response.text

2枚の画像とプロンプトを一緒に渡して、類似理由を自然言語で返してもらいます。


結果

類似度行列

=== Similarity Matrix ===
rows/cols = ['butter_chicken_nun..jpg', 'butter_chicken_rice.jpg', 'dry_curry.jpg', 'hayashi_style.jpg', 'katsu_curry.jpg', 'keema_curry.jpg', 'soup_curry.jpg', 'vegetable_curry.jpg']
[[1.     0.9129 0.7419 0.7769 0.782  0.8096 0.7431 0.8052]
 [0.9129 1.     0.7556 0.845  0.8291 0.8077 0.765  0.8704]
 [0.7419 0.7556 1.     0.7684 0.794  0.8249 0.7652 0.7851]
 [0.7769 0.845  0.7684 1.     0.8512 0.8099 0.7635 0.8159]
 [0.782  0.8291 0.794  0.8512 1.     0.7658 0.8009 0.8275]
 [0.8096 0.8077 0.8249 0.8099 0.7658 1.     0.7448 0.8135]
 [0.7431 0.765  0.7652 0.7635 0.8009 0.7448 1.     0.8241]
 [0.8052 0.8704 0.7851 0.8159 0.8275 0.8135 0.8241 1.    ]]

ヒートマップにすると傾向が一目でわかります。

image.png

各画像の最も似ている相手

クエリ 最も類似 スコア
butter_chicken_naan butter_chicken_rice 0.9129
butter_chicken_rice butter_chicken_naan 0.9129
dry_curry keema_curry 0.8249
hayashi_style katsu_curry 0.8512
katsu_curry hayashi_style 0.8512
keema_curry dry_curry 0.8249
soup_curry vegetable_curry 0.8241
vegetable_curry butter_chicken_rice 0.8704
=== Query: butter_chicken_nun..jpg ===
0.9129  butter_chicken_rice.jpg
0.8096  keema_curry.jpg
0.8052  vegetable_curry.jpg

=== Query: butter_chicken_rice.jpg ===
0.9129  butter_chicken_nun..jpg
0.8704  vegetable_curry.jpg
0.8450  hayashi_style.jpg

=== Query: dry_curry.jpg ===
0.8249  keema_curry.jpg
0.7940  katsu_curry.jpg
0.7851  vegetable_curry.jpg

=== Query: hayashi_style.jpg ===
0.8512  katsu_curry.jpg
0.8450  butter_chicken_rice.jpg
0.8159  vegetable_curry.jpg

=== Query: katsu_curry.jpg ===
0.8512  hayashi_style.jpg
0.8291  butter_chicken_rice.jpg
0.8275  vegetable_curry.jpg

=== Query: keema_curry.jpg ===
0.8249  dry_curry.jpg
0.8135  vegetable_curry.jpg
0.8099  hayashi_style.jpg

=== Query: soup_curry.jpg ===
0.8241  vegetable_curry.jpg
0.8009  katsu_curry.jpg
0.7652  dry_curry.jpg

=== Query: vegetable_curry.jpg ===
0.8704  butter_chicken_rice.jpg
0.8275  katsu_curry.jpg
0.8241  soup_curry.jpg

LLM による類似理由の解説(抜粋)

バターチキン(ナン)↔ バターチキン(ライス): 0.9129

  • 鮮やかなオレンジ色のとろみのあるソース料理がメインで、白いクリーム状のソースと緑の葉が添えられている点。
  • 白い円形の皿に盛られた料理が、木目のテーブル上で、斜め上からの俯瞰気味の構図で捉えられている点。
  • メイン料理の横に白い主食(平たいパンまたは半球状のご飯)が添えられ、それぞれに黒い粒々や緑の葉が飾られている点。

同じ料理の盛り付け違いなので、ソースの色・質感・トッピングが共通し、最高スコアが出ています。

ハヤシライス風 ↔ カツカレー: 0.8512

  • 白米とメインの具材が左右に分かれて盛り付けられた、同形状・同質感の皿を使用している点。
  • メインの具材には茶色系のソースがかかっており、その上に緑色の薬味や具材が添えられている点。
  • 木目のテーブルに料理が置かれ、背景に店内風景と窓からの光が見えるという、撮影の構図や雰囲気が類似している点。

料理としてはハヤシとカツカレーで別物ですが、「白い皿に白米+茶色ソースが左右分割で盛られている」という構図パターンが一致し、高スコアになっています。

その他のペアの解説(クリックで展開)

ドライカレー ↔ キーマカレー: 0.8249

  • どちらの料理も、挽肉を主たる具材とし、茶色を基調とした色味に、細かく刻まれた野菜(人参、グリーンピースなど)の緑やオレンジが彩りを添えている点。
  • 白系の陶器製の大皿に盛り付けられ、木製のテーブルの上に置かれた構図が共通しており、自然光のような明るい照明の下で撮影されている点。
  • 料理の表面に刻んだ緑色のハーブ(パセリやコリアンダー)が散らされており、視覚的な彩りとアクセントになっている点。

スープカレー ↔ 野菜カレー: 0.8241

  • 両画像ともに、黄色みがかった温かみのある色合いのソースまたはスープが特徴で、具材の表面にとろみが絡むような質感が共通しています。
  • どちらの料理も白米が添えられており、メインの料理には大きめにカットされた野菜が複数種使われ、その上に新鮮な緑色のハーブが飾りとして散りばめられています。
  • 木製のテーブルを背景に、明るい照明の下で料理が中央に配置され、奥にはグラスに入った水が見える構図に類似性が見られます。

野菜カレー ↔ バターチキン(ライス): 0.8704

  • 白い丸皿に半球状に盛られた白米と、その横にメインのソース料理が配置されている構図が共通しています。
  • どちらの白米も粒立ちが良く、上には少量の黒い粒状のスパイスが散らされています。
  • とろみのあるソース料理に具材が入り、刻んだ香草(パクチー)がトッピングとして添えられている点が共通しています。

考察

svg_07_discussion.png

今回の結果から見えてきた傾向をまとめます。視覚的特徴(見た目)の影響が強く出た。

同じ料理の別盛り付けがスコア高い

バターチキン(ナン版 vs ライス版)の 0.9129 は他のどのペアよりも明確に高いスコアでした。ソースの色・質感・トッピングのパターンがほぼ同一なので、embedding が「料理そのもの」の視覚的特徴を捉えていることがわかります。

構図と色調が一致すると、別料理でもスコアが高い

ハヤシライス風とカツカレーは料理のジャンルとしては別物ですが、「白い皿、片側に白米、もう片側に茶色いソース、緑の薬味」という盛り付けパターンが一致しており、0.8512 という高いスコアになりました。同様に、野菜カレーとバターチキン(ライス版)も「半球状のライス+とろみソース+パクチー」の構図一致で 0.8704 を記録しています。

これは embedding モデルが「何の料理か」という意味的カテゴリよりも、「どう見えるか」という視覚的構造に重きを置いていることを示唆しています。

テクスチャ (見た目や触ったときの質感・表面の特徴) の類似も拾える

ドライカレーとキーマカレーのペア(0.8249)は、どちらも「そぼろ状の挽肉+細かい野菜」というテクスチャが共通しています。盛り付けの構図は異なる(ドライカレーは目玉焼き乗せ、キーマはライス添え)のに、表面の質感パターンで類似度が引き上げられている印象です。

スープカレーは孤立気味

スープカレーは他のどの画像とも類似度が比較的低く、最も似ている野菜カレーでも 0.8241 にとどまりました。「深い器にスープ状の液体+大きくカットされた具材」という見た目は、皿に盛られた他のカレー類とは構造的に異なるためでしょう。

実用上の示唆

この特性を踏まえると、Gemini Embedding 2 の画像 embedding は「料理の種類で検索したい」用途よりも、「見た目が似た料理を探したい」「盛り付けのパターンで分類したい」といった視覚的類似性ベースのタスクに向いていると言えます。

もし料理カテゴリとしての類似度も取りたい場合は、画像にキャプションを付けてからテキスト embedding するアプローチや、画像 embedding とテキスト embedding を組み合わせるハイブリッド検索が有効かもしれません。


実装例

最後に、本記事で使用したコードの全体を掲載します。

コード全体(クリックで展開)
# Colab:
# !pip install -q google-genai pillow matplotlib numpy

from google.colab import userdata
from google import genai
from google.genai import types

from PIL import Image
import numpy as np
import matplotlib.pyplot as plt
import os
import glob
import time

# =========================
# APIクライアント
# =========================
client = genai.Client(api_key=userdata.get("GOOGLE_API_KEY"))

# =========================
# MIME type 判定
# =========================
def guess_mime_type(image_path: str) -> str:
    ext = os.path.splitext(image_path)[1].lower()
    if ext in [".jpg", ".jpeg"]:
        return "image/jpeg"
    elif ext == ".png":
        return "image/png"
    elif ext == ".webp":
        return "image/webp"
    else:
        raise ValueError(f"Unsupported image type: {ext}")

# =========================
# 画像Embedding
# =========================
def embed_image(image_path: str) -> np.ndarray:
    with open(image_path, "rb") as f:
        image_bytes = f.read()

    result = client.models.embed_content(
        model="gemini-embedding-2-preview",
        contents=[
            types.Part.from_bytes(
                data=image_bytes,
                mime_type=guess_mime_type(image_path),
            )
        ]
    )

    return np.array(result.embeddings[0].values, dtype=np.float32)

# =========================
# cosine類似度
# =========================
def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float:
    denom = np.linalg.norm(a) * np.linalg.norm(b)
    if denom == 0:
        return 0.0
    return float(np.dot(a, b) / denom)

# =========================
# フォルダから画像を収集
# =========================
def load_image_paths_from_folder(folder_path: str):
    patterns = ["*.png", "*.jpg", "*.jpeg", "*.webp"]
    image_paths = []

    for pattern in patterns:
        image_paths.extend(glob.glob(os.path.join(folder_path, pattern)))

    return sorted(image_paths)

# =========================
# 類似度行列作成
# =========================
def build_similarity_matrix(index):
    n = len(index)
    sim_matrix = np.zeros((n, n), dtype=np.float32)

    for i in range(n):
        for j in range(n):
            sim_matrix[i, j] = cosine_similarity(
                index[i]["embedding"],
                index[j]["embedding"]
            )

    return sim_matrix

# =========================
# ヒートマップ表示
# =========================
def show_similarity_heatmap(sim_matrix, labels):
    plt.figure(figsize=(8, 6))
    plt.imshow(sim_matrix)
    plt.colorbar(label="Cosine Similarity")
    plt.xticks(range(len(labels)), labels, rotation=45, ha="right")
    plt.yticks(range(len(labels)), labels)
    plt.title("Image Similarity Matrix")
    plt.tight_layout()
    plt.show()

# =========================
# 各画像ごとの上位類似画像を表示
# =========================
def print_top_similar_per_image(index, sim_matrix, top_k=3):
    n = len(index)

    for i in range(n):
        base_name = os.path.basename(index[i]["path"])
        print(f"\n=== Query: {base_name} ===")

        pairs = []
        for j in range(n):
            if i == j:
                continue
            pairs.append({
                "path": index[j]["path"],
                "score": float(sim_matrix[i, j])
            })

        pairs = sorted(pairs, key=lambda x: x["score"], reverse=True)

        for row in pairs[:top_k]:
            print(f'{row["score"]:.4f}  {os.path.basename(row["path"])}')

# =========================
# 画像比較を可視化
# =========================
def show_top_similar_images(index, sim_matrix, query_idx=0, top_k=3):
    query_path = index[query_idx]["path"]

    pairs = []
    for j in range(len(index)):
        if j == query_idx:
            continue
        pairs.append({
            "path": index[j]["path"],
            "score": float(sim_matrix[query_idx, j])
        })

    pairs = sorted(pairs, key=lambda x: x["score"], reverse=True)

    plt.figure(figsize=(4 * (top_k + 1), 4))

    plt.subplot(1, top_k + 1, 1)
    plt.imshow(Image.open(query_path))
    plt.title(f"Query\n{os.path.basename(query_path)}")
    plt.axis("off")

    for i, row in enumerate(pairs[:top_k], start=2):
        plt.subplot(1, top_k + 1, i)
        plt.imshow(Image.open(row["path"]))
        plt.title(f'{os.path.basename(row["path"])}\n{row["score"]:.3f}')
        plt.axis("off")

    plt.tight_layout()
    plt.show()

# =========================
# retry ヘルパー
# =========================
def run_with_retry(func, max_retries=3, sleep_sec=10):
    last_error = None

    for attempt in range(1, max_retries + 1):
        try:
            return func()
        except Exception as e:
            last_error = e
            print(f"[Retry {attempt}/{max_retries}] {e}")

            if attempt < max_retries:
                print(f"  -> {sleep_sec}秒待機して再試行します")
                time.sleep(sleep_sec)

    raise last_error

# =========================
# LLMで類似理由を説明
# =========================
def explain_similarity_with_llm(query_path: str, target_path: str, score: float) -> str:
    def _call():
        with open(query_path, "rb") as f:
            query_bytes = f.read()

        with open(target_path, "rb") as f:
            target_bytes = f.read()

        prompt = f"""
次の2枚の料理画像について、日本語で簡潔に説明してください。

出力ルール:
- 3点だけ箇条書き
- 1行目に「類似度: {score:.4f}」
- 「似ている点」を中心に書く
- 推測しすぎない
- 料理名の断定より、見た目・構図・質感・色味・具材の配置に注目する
"""

        response = client.models.generate_content(
            model="gemini-2.5-flash",
            contents=[
                prompt,
                types.Part.from_bytes(
                    data=query_bytes,
                    mime_type=guess_mime_type(query_path),
                ),
                types.Part.from_bytes(
                    data=target_bytes,
                    mime_type=guess_mime_type(target_path),
                ),
            ],
        )

        return response.text

    return run_with_retry(_call, max_retries=3, sleep_sec=10)

# =========================
# 各画像の最も似ている1件をLLMで解説
# =========================
def explain_best_match_per_image(index, sim_matrix, request_interval_sec=10):
    n = len(index)

    for i in range(n):
        query_path = index[i]["path"]

        best_j = None
        best_score = -1.0

        for j in range(n):
            if i == j:
                continue
            score = float(sim_matrix[i, j])
            if score > best_score:
                best_score = score
                best_j = j

        target_path = index[best_j]["path"]

        print(f"\n=== Query: {os.path.basename(query_path)} ===")
        print(f"Best match: {os.path.basename(target_path)} ({best_score:.4f})")

        explanation = explain_similarity_with_llm(query_path, target_path, best_score)
        print(explanation)

        print(f"[sleep] 次のLLM呼び出しまで {request_interval_sec} 秒待機")
        time.sleep(request_interval_sec)

# =========================
# メイン処理
# =========================
folder_path = "/content/curry_images"

image_paths = load_image_paths_from_folder(folder_path)

if not image_paths:
    raise ValueError(f"No images found in folder: {folder_path}")

print("Loaded images:")
for path in image_paths:
    print("-", os.path.basename(path))

# インデックス作成
index = []
for path in image_paths:
    print(f"Embedding: {os.path.basename(path)}")
    index.append({
        "path": path,
        "embedding": embed_image(path)
    })

print(f"\nindexed: {len(index)} images")

# 類似度行列
sim_matrix = build_similarity_matrix(index)
labels = [os.path.basename(item["path"]) for item in index]

print("\n=== Similarity Matrix ===")
print("rows/cols =", labels)
print(np.round(sim_matrix, 4))

# ヒートマップ表示
show_similarity_heatmap(sim_matrix, labels)

# 各画像ごとの上位3件
print_top_similar_per_image(index, sim_matrix, top_k=3)

# 先頭画像をクエリにして上位3件を画像表示
show_top_similar_images(index, sim_matrix, query_idx=0, top_k=3)

# 各画像の最も似ている1件をLLMで説明
explain_best_match_per_image(index, sim_matrix, request_interval_sec=10)

Google Colaboratory 上で、以下のように試してみました。画像ファイルはcurry_imagesフォルダを作成して手動でUPしました。

image.png


まとめ

あくまでも現状のPreview試してみてです。

svg_03_summary.png

Gemini Embedding 2 の画像 embedding を使って8種のカレー画像を比較した結果、以下のことがわかりました。

  • 同じ料理の盛り付け違い(バターチキン+ナン vs バターチキン+ライス)が最高の類似度 0.91 を記録し、embedding がソースの色・質感・トッピングを正確に捉えていることを確認できた
  • 構図と色調のパターンが一致する別料理(ハヤシ vs カツカレー)も 0.85 と高いスコアが出るため、embedding は「何の料理か」よりも「どう見えるか」を重視している傾向がある
  • 挽肉のテクスチャ(ドライカレー vs キーマ)やスープ状の形態(スープカレー vs 野菜カレー)など、料理表面の質感レベルの類似性も反映されている

ユースケース

svg_04_usecase.png

この特性を活かすなら、以下のようなユースケースが考えられます。

  • 見た目が似た料理のレコメンド
  • 盛り付けパターンの分類
  • 似た構図の画像を集めてカタログを作成
  • 過去の写真と比較して差分を検出し、異なるパターンの作成に活用
  • AI に違いを分析させ、改善案やバリエーションを提案させる

また、類似度スコアだけでは「どこがどう違うのか」までは分からないため、LLM に画像の違いを説明させるアプローチも有効だと感じました。今回はその点を補うために、類似度の高い画像ペアに対して「なぜ似ているのか/どこが違うのか」を言語化させる形で試しています。

料理カテゴリによる検索を重視する場合は、テキスト embedding との組み合わせを検討すると良さそうです。

感想

svg_05_impression.png

Gemini Embedding 2 はまだ Preview 段階ですが、画像を渡すだけで高品質な embedding が取れる手軽さは魅力的です。

特に、「何が写っているか」だけでなく「どう見えるか(構図・色・質感)」を強く捉えている点は印象的でした。画像検索やレコメンドだけでなく、クリエイティブ用途にも応用できそうです。

テキスト・画像・音声・動画を同一空間にマッピングできるので、マルチモーダル検索の実験がとてもやりやすくなりました。


参考

解説動画

4
6
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?