画像の回転などの変換処理が埋め込みベクトルに与える影響を確認してみた
こんちには。
データアナリティクス事業本部 機械学習チームの中村(nokomoro3)です。
今回は小ネタですが、画像の反転や位置の違いが埋め込みベクトルにどの程度影響を与えるのか知りたかったので、本記事で試してみたいと思います。
使用する画像
ネコの画像をいらすとやから拝借しました。
こちらに対して、以下のような変換を加えてみます。
- サイズ変更
- 回転
- 位置替え
- モノクロ化
そしてネコ以外の画像も比較のため、以下のイヌの画像も使ってみます。
これらの画像データを Titan Multimodal Embeddings で埋め込みベクトルに変換して、コサイン類似度を比較してみようと思います。
埋め込みモデルについて
使用する埋め込みモデルは以下を用います。
こちらはテキストおよび画像ファイルを入力として扱い、デフォルトで1024次元の埋め込みベクトルを出力します。
今回はPython(Boto3)で埋め込みベクトルに変換します、以下のブログからコードをお借りしました。
埋め込みベクトルを作成する関数は以下となります。
def get_embedding(bedrock, image_path: str, dimensions: int = 1024) -> List[float]:
with open(image_path, "rb") as image:
body = image.read()
response = bedrock.invoke_model(
body=json.dumps(
{
"inputImage": base64.b64encode(body).decode("utf8"),
"embeddingConfig": {"outputEmbeddingLength": dimensions},
}
),
modelId="amazon.titan-embed-image-v1",
accept="application/json",
contentType="application/json",
)
response_body = json.loads(response.get("body").read())
return response_body.get("embedding")
作成した埋め込みベクトルを使って、コサイン類似度を計算し、画像間の類似性を確認してみたいと思います。
結果
まずは結果からお示しします。類似度が高い順に棒グラフにしてみました。
イヌとネコのコサイン類似度が一つ基準となり 0.710
程度となっています。
ですので今回の実験に限って言いますと、0.710
程の違いがでると違う画像になっているという風に認識しても良さそうです。
それよりも差が出たものとしては、白色ノイズが強めにかかっている場合は 0.677
程度になることもありました。
ネコ自体の数が増えているケースも類似度が 0.836
程度には下がり、
90°などの回転処理もそれに近いくらいの 0.850
までは類似度が下がっているようです。
またモノクロ化は意外と差異がなく 0.879
程度となっています。
この辺りは今後の処理の参考になりそうです。
また変換のカテゴリごとの値は以下の表をご確認ください。(グラフと同様の値ですが、カテゴリ分けしています)
類似度 | ファイル名 |
---|---|
1.000 | ネコ.png |
0.710 | イヌ.png |
0.869 | ネコ_背景=緑.png |
0.899 | ネコ_背景=赤.png |
0.906 | ネコ_背景=青.png |
0.949 | ネコ_回転=45.png |
0.850 | ネコ_回転=90.png |
0.858 | ネコ_回転=180.png |
0.965 | ネコ_リサイズ(アスペクト比固定)=172x200.png |
0.998 | ネコ_リサイズ(アスペクト比固定)=690x800.png |
0.887 | ネコ_リサイズ(アスペクト比無視)=345x800.png |
0.971 | ネコ_リサイズ(アスペクト比無視)=690x400.png |
0.873 | ネコ_位置変更=(0.2,0.2).png |
0.889 | ネコ_位置変更=(0.2,0.8).png |
0.880 | ネコ_位置変更=(0.8,0.2).png |
0.906 | ネコ_位置変更=(0.8,0.8).png |
0.836 | ネコ_増殖.png |
0.879 | ネコ_モノクロ.png |
0.829 | ネコ_ノイズ=ペッパー.png |
0.959 | ネコ_ノイズ=白色強度10.png |
0.771 | ネコ_ノイズ=白色強度50.png |
0.677 | ネコ_ノイズ=白色強度100.png |
以降はコードの説明になります。
コード
必要なライブラリのインポート
必要なライブラリを以下のようにインポートします。pipやpoetryなど環境に合ったものをお使いください。
import json
import base64
from typing import List
import os
import pathlib
import boto3
from PIL import Image
import numpy as np
import polars as pl
参考までにpyproject.tomlを置いておきます。
[tool.poetry]
name = "image-embedding"
version = "0.1.0"
description = ""
authors = ["Your Name <[email protected]>"]
readme = "README.md"
[tool.poetry.dependencies]
python = "^3.12"
ipykernel = "^6.29.4"
boto3 = "^1.34.126"
pillow = "^10.3.0"
numpy = "^1.26.4"
polars = "^0.20.31"
plotly = "^5.22.0"
pandas = "^2.2.2"
pyarrow = "^16.1.0"
nbformat = "^5.10.4"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
AWSプロファイルの設定
boto3を使用しますのでプロファイルを設定しておきます
os.environ["AWS_PROFILE"] = "{AWSプロファイル名}"
画像を保存する関数
共通で使用する関数として、背景を白色にして保存する関数を作っておきます。(透過性をどのように処理するか不明なため、一旦白塗りで保存します)
def save_image(image, save_path: str, bg_color=(255, 255, 255, 255)):
# 画像をRGBAモードに変換する
rgba_image = image.convert("RGBA")
# 白色の背景画像を作成する
background = Image.new("RGBA", rgba_image.size, bg_color)
# 透過画像を白色の背景画像に重ねる
result = Image.alpha_composite(background, rgba_image)
# 結果の画像をRGBモードに変換する
rgb_result = result.convert("RGB")
# 変更した画像を保存する
rgb_result.save(save_path)
標準画像の保存
ネコについて背景を白背景にして保存し、これを標準画像としています。
元ファイルは背景透過になっており、./img/org/
配下に保存しているとします。標準画像は他の画像と同様に ./img/
配下に保存します。
image = Image.open("./img/org/ネコ.png")
save_image(image, "./img/ネコ.png")
また、イヌ画像も同様の処理をします。
image = Image.open("./img/org/イヌ.png")
save_image(image, "./img/イヌ.png")
基本的には、この後の変換も ./img/org/
側のデータを使い、保存時に背景を塗る形とします。
背景画像の変更
背景色によるベクトルの変化を調べるため、3色の背景を保存します。
image = Image.open("./img/org/ネコ.png")
save_image(image, "./img/ネコ_背景=赤.png", bg_color=(255, 0, 0, 255))
save_image(image, "./img/ネコ_背景=緑.png", bg_color=( 0, 255, 0, 255))
save_image(image, "./img/ネコ_背景=青.png", bg_color=( 0, 0, 255, 255))
回転
回転によるベクトルの変化を調べるため、回転させた画像を3種類作成します。
image = Image.open("./img/org/ネコ.png")
for angle in [45, 90, 180]:
# 画像を90度回転させる
rotated_image = image.rotate(angle=angle)
# 保存
save_image(rotated_image, f"./img/ネコ_回転={angle}.png")
リサイズ(アスペクト比固定)
リサイズによる変化を確認するため、まずはアスペクト比を維持した状態でどうなるか確認します。
image = Image.open("./img/org/ネコ.png")
# 元画像のアスペクト比を計算
width, height = image.size
aspect_ratio = width / height
for new_size in [
(width*2, height*2),
(width//2, height//2),
]:
# 新しいサイズを計算する
if width > height:
new_width = new_size[0]
new_height = int(new_width / aspect_ratio)
else:
new_height = new_size[1]
new_width = int(new_height * aspect_ratio)
# アスペクト比を維持したままサイズ変更する
resized_image = image.resize((new_width, new_height))
# 保存
save_image(resized_image, f"./img/ネコ_リサイズ(アスペクト比固定)={new_size[0]}x{new_size[1]}.png")
リサイズ(アスペクト比無視)
リサイズによる変化を確認するため、次はアスペクト比を無視した状態でどうなるか確認します。
image = Image.open("./img/org/ネコ.png")
# 元画像のアスペクト比を計算する
width, height = image.size
aspect_ratio = width / height
for new_size in [
(width*2, height),
(width, height*2),
]:
# アスペクト比を維持したままサイズ変更する
resized_image = image.resize((new_size[0], new_size[1]))
# 保存
save_image(resized_image, f"./img/ネコ_リサイズ(アスペクト比無視)={new_size[0]}x{new_size[1]}.png")
位置変更
画像内で位置がずれた影響を確認するため、位置をずらして確認します。
image = Image.open("./img/org/ネコ.png")
for locale in [
(0.8, 0.8),
(0.8, 0.2),
(0.2, 0.8),
(0.2, 0.2),
]:
# 新しい背景のサイズを指定する
new_background_size = (image.size[0]*2, image.size[1]*2) # (幅, 高さ)
# 新しい背景画像を作成する
background = Image.new("RGBA", new_background_size, (255, 255, 255, 255)) # 白色の背景
# 元の画像を新しい背景画像に貼り付ける位置を計算する
image_width, image_height = image.size
background_width, background_height = new_background_size
paste_position = (
int((background_width - image_width ) * locale[0]),
int((background_height - image_height) * locale[1]),
)
# 元の画像を新しい背景画像に貼り付ける
background.paste(image, paste_position, mask=image)
# 変更した画像を保存する
save_image(background, f"./img/ネコ_位置変更=({locale[0]},{locale[1]}).png")
増殖
位置変更の過程で増殖させるとどうなるのだろうと興味が湧いたのでこちらも試しました。
image = Image.open("./img/org/ネコ.png")
# 新しい背景のサイズを指定する
new_background_size = (image.size[0]*2, image.size[1]*2) # (幅, 高さ)
# 新しい背景画像を作成する
background = Image.new("RGBA", new_background_size, (255, 255, 255, 255)) # 白色の背景
for locale in [
(1.0, 1.0),
(1.0, 0.0),
(0.0, 1.0),
(0.0, 0.0),
]:
# 元の画像を新しい背景画像に貼り付ける位置を計算する
image_width, image_height = image.size
background_width, background_height = new_background_size
paste_position = (
int((background_width - image_width ) * locale[0]),
int((background_height - image_height) * locale[1]),
)
# 元の画像を新しい背景画像に貼り付ける
background.paste(image, paste_position, mask=image)
# 変更した画像を保存する
save_image(background, "./img/ネコ_増殖.png")
モノクロ
モノクロ化によるベクトル変化を確認したいため、こちらも試します。
モノクロ処理は、背景を白色にした後にしないと背景が黒くなるため、入力としては既に背景を白色にしたものを使います。
image = Image.open("./img/ネコ.png")
# 画像をモノクロに変換する
monochrome_image = image.convert("L")
# 保存
save_image(monochrome_image, "./img/ネコ_モノクロ.png")
ペッパーノイズ
ノイズによる影響を調べます。まずはペッパーノイズです。
# 画像をnumpy配列に変換する
image_array = np.array(image)
# ゴマ粒ノイズを加える
noise_density = 0.01 # ノイズの密度を調整する
salt_noise = np.random.random(image_array.shape) < noise_density
pepper_noise = np.random.random(image_array.shape) < noise_density
image_array[salt_noise] = 255 # 白色のゴマ粒ノイズ
image_array[pepper_noise] = 0 # 黒色のゴマ粒ノイズ
# 保存
save_image(Image.fromarray(image_array), "./img/ネコ_ノイズ=ペッパー.png")
白色ノイズ
次は白色ノイズによる影響を、強度を変えて確認します。
for white_noise_strength in [10, 50, 100]:
# 画像をnumpy配列に変換する
image_array = np.array(image)
# 弱いホワイトノイズを加える
white_noise = np.random.normal(0, white_noise_strength, image_array.shape)
image_array = np.clip(image_array + white_noise, 0, 255).astype(np.uint8)
# 保存
save_image(Image.fromarray(image_array), f"./img/ネコ_ノイズ=白色強度{white_noise_strength}.png")
ベクトル化
作成した画像をすべてベクトル化して vectors
という辞書型に入れておきます。
# この関数は再掲
def get_embedding(bedrock, image_path: str, dimensions: int = 1024) -> List[float]:
with open(image_path, "rb") as image:
body = image.read()
response = bedrock.invoke_model(
body=json.dumps(
{
"inputImage": base64.b64encode(body).decode("utf8"),
"embeddingConfig": {"outputEmbeddingLength": dimensions},
}
),
modelId="amazon.titan-embed-image-v1",
accept="application/json",
contentType="application/json",
)
response_body = json.loads(response.get("body").read())
return response_body.get("embedding")
bedrock = boto3.client(service_name="bedrock-runtime", region_name="us-east-1")
vectors = {}
for image_path in pathlib.Path("./img").glob("*.png"):
vector = get_embedding(
bedrock=bedrock,
image_path=image_path)
vectors[image_path.name] = vector
類似度計算
全組み合わせでコサイン類似度を計算して、polarsのDataFrameにしておきます。
def cosine_similarity(vec1: list[float], vec2: list[float]):
vec1_np = np.asarray(vec1)
vec2_np = np.asarray(vec2)
# ベクトルの内積を計算
dot_product = np.dot(vec1_np, vec2_np)
# 各ベクトルの大きさ(ノルム)を計算
norm_vec1 = np.linalg.norm(vec1_np)
norm_vec2 = np.linalg.norm(vec2_np)
# コサイン類似度を計算
cosine_sim = dot_product / (norm_vec1 * norm_vec2)
return cosine_sim
similarities = []
keys = sorted(vectors.keys())
for i in keys:
for j in keys:
vec1 = vectors[i]
vec2 = vectors[j]
similarity = cosine_similarity(vec1=vec1, vec2=vec2)
similarities.append(
{"vec1": i, "vec2": j, "similarity": similarity}
)
df = pl.DataFrame(similarities).sort(["vec1", "vec2"])
cross_tabulation = df.pivot(values='similarity', index='vec1', columns='vec2').fill_null(0)
類似度の可視化
最後にplotly.expressで棒グラフを描きます。その際、すべての組み合わせのコサイン類似度は不要なため、 ネコ.png
を使って計算した類似度に限定するようにフィルタをかけています。
# 棒グラフの作成
fig = px.bar(
df.filter(pl.col("vec1") == "ネコ.png").select(["vec2", "similarity"]).sort("similarity", descending=True),
x="similarity", y="vec2", orientation="h", width=1000, text="similarity", height=600, range_x=[0, 1.5])
fig.update_yaxes(autorange="reversed")
fig.update_traces(textposition='outside')
# グラフの表示
fig.show()
まとめ
いかがでしたでしょうか。本記事が画像をベクトル化を試す際のご参考になれば幸いです。