昨今、ベクトル検索に絡む投稿を続けていますが、実は真の目的は画像検索を行うことでした。
ある画像が指定された際に、既に登録済みの画像の中から指定された画像と最も類似度の高い画像を抽出してくるような機能を実現したいと思っていて、その手段として適当だと思われたのがベクトル検索でした。
ただ、そうなると画像データをベクトル化(つまりはエンベディング)する必要がある訳で、この点に関して手軽さと相応の精度を兼ね備えた方法を探していたところ、Gemini先生に「TensorFlow/Kerasが良いよ〜」というような助言をいただいたので、今回はTensorFlow/Kerasを使って画像データのエンベディングを実施します。
なお、個人的にはTensorFlow/Kerasに加えてAI関連全般に関して無知で、かつ同機能を使用するとなると言語はやはりpythonになるのですが、こちらに関してもほぼ素人なので、実施する内容の大半はGemini先生の言いなりです。
まずは、結果を出すことを優先します。
また、環境については先のPostgreSQL17関連の実験で構築した環境(Mac + VirtualBox + Vagrant + Ubuntu22.04)を継続して使用します。
Pythonの環境構築
Pythonの環境構築ですが、前述のUbuntu22.04の環境構築時に既に下記バージョンがインストールされていました。
よって、これをそのまま使用します。
# python3 --version
Python 3.10.12
上記環境にTensorFlowのパッケージを追加する必要があるのですが、Pythonのパッケージ管理ツールとして「pip」なるものがあるようで、まずはこれをインストールします。
# apt install python3-pip
pipがインストールできたら改めてTensorFlowをインストールしますが、AI関連機能ではGPUを使用して処理の高速化を行なっているものも多く、TensorFlowもデフォルトではGPU版がインストールされてしまいます。
最初に書いたように今回の動作環境はVirtualBox上にあるため最寄りのGPUが存在せず、TensorFlowのGPU版は正しく動作しないようです(回避策もあるっぽいですが、幾つか試した範囲ではうまくいきませんでした)。
よって、GPUに依存せず、CPUのみで動作するもの(CPU版)を明示的に指定してインストールします。
# pip install tensorflow-cpu
なお、今回実施する処理では「numpy」なる科学技術計算関連パッケージも必要なのですが、TensorFlowのインストール時に併せてインストールされるようです。
結果は以下のようになります。
# pip list | grep tensorflow
tensorflow_cpu 2.18.0
tensorflow-io-gcs-filesystem 0.37.1
# pip list | grep numpy
numpy 2.0.2
エンベディングの実行
TensorFlowを利用したエンベディングの処理内容は以下の通りです。
ある程度条件を指定したり、アレンジしたりはしていますが、基本的にはGemini先生作です。
import numpy as np
import tensorflow as tf
model = tf.keras.applications.ResNet50(include_top=False, pooling="avg", weights='imagenet')
def embedding(image_path):
image_bytes = tf.io.read_file(image_path)
decoded_image = tf.image.decode_jpeg(image_bytes, channels=3)
resized_image = tf.image.resize(decoded_image, [224, 224])
vector = model.predict(np.array([resized_image.numpy()]))[0]
return vector
image_path = "./1.jpg"
vector = embedding(image_path)
print(vector)
print(vector.shape)
print(vector.dtype)
上記処理の概要をGemini先生に解説してもらった結果が以下です。
このコードは、与えられた画像のパスから画像を読み込み、ResNet50という深層学習モデルを用いて特徴ベクトルを抽出する処理を行っています。特徴ベクトルとは、画像の内容を数値で表現したもので、画像検索や画像分類などのタスクで利用されます。
画像検索で利用する特徴ベクトルの抽出手段として適当そうです。
重要な部分に関して個別にGemini先生に解説してもらいましょう。
言葉足らずの部分に関しては適宜補足していきます。
model = tf.keras.applications.ResNet50(include_top=False, pooling="avg", weights='imagenet')
このコード行は、事前学習済みのResNet50モデルをTensorFlow/Kerasを用いてロードする処理を行っています。
ResNet50は、深い層を持つ畳み込みニューラルネットワーク(CNN)の一種で、画像認識において高い性能を発揮します。
include_top=False: ResNet50の最終的な全結合層(分類層)を除外します。これにより、画像分類ではなく、画像の特徴抽出を行うためのモデルとして使用します。
pooling="avg": 特徴マップを平均プーリングして、固定長のベクトルを得ます。これにより、入力画像のサイズに関わらず、出力ベクトルの次元が一定になります。
weights='imagenet': ImageNetという大規模な画像データセットで事前学習された重みを使用します。これにより、初期状態から学習するよりも高い精度で特徴抽出を行うことができます。
ResNet50は大きく特徴抽出部と分類器の2つの部分で構成されているようで、特徴抽出部は入力画像の特徴を数値ベクトルとして抽出する部分であり、分類器は抽出された特徴ベクトルを元に画像がどのクラスに属するかを分類する部分とのことです。
「include_top=False」とすることで特徴抽出部のみが機能し、結果として特徴ベクトルの取得ができるという訳ですね。
「特徴マップ」という言葉が出てきますが、これは簡単に言うと「画像の情報を数値化して整理したもの」らしく、画像の特定の領域にどのような特徴があるかを数値化し、集めたものと言うことらしいです。
なお、特徴としては以下のような表現されるようです。
- エッジ: 画像の輪郭や線
- 形状: 円、四角形などの形状
- テクスチャ: 表面の様子(滑らかさ、ザラザラなど)
- 色: 画像の色に関する情報
一方で、「プーリング」とは「特徴マップのサイズを縮小する操作」とのことです。それにより計算量を減らし、過学習を防ぎ、位置不変性を実現するらしいですが…よく分かりません。
いずれにしてもプーリングには「最大プーリング」と「平均プーリング」があって、「pooling=”avg”」は平均プーリングを使用するよう指定しています。
「重み」に関しては「各入力データが最終的な出力に与える影響の大きさを数値化したもの」といった説明が見受けられますが、これだけでは今ひとつ良く分かりませんね。
ニューラルネットワークの中では多くの数値が処理されますが、その全てが同等に重要と言う訳ではないようです。今回のケースで言えば、ある画像の特徴を数値化していく過程において、よりその画像の識別に役立つ数値とそうでもない数値が混在していると言ったところでしょうか。
そのような点を考慮して、より有益な数値が重視されるよう補正しながら処理を行なっていくようですが、その補正に用いられる数値が「重み」と言うことのようです。
「weights=’imagenet’」と指定することで、「ImageNet」なる大規模画像データを用いて学習された「重み」が使用されるようで、例えるなら熟練者の経験則をそのまま丸パクリしたと言った感じかと思います。
image_bytes = tf.io.read_file(image_path)
指定された画像パスから画像データをバイナリ形式で読み込みます。
decoded_image = tf.image.decode_jpeg(image_bytes, channels=3)
読み込んだバイナリデータをJPEG形式の画像データに変換します。
resized_image = tf.image.resize(decoded_image, [224, 224])
画像サイズをResNet50モデルの入力サイズである224x224にリサイズします。
上記3つの処理で所定の画像を読み込んでサイズを224×224に変換しています。
224×224への変換は上記にあるようにResNet50がそのサイズを期待しているからと言うことなのでしょう。
vector = model.predict(np.array([resized_image.numpy()]))[0]
リサイズした画像をNumPy配列に変換し、ResNet50モデルに入力して特徴ベクトルを計算します。
色々とまとめて処理されているので、もう少し分解してみましょう。
resized_image.numpy()
resized_image は、リサイズされた画像のTensorです。
numpy() メソッドは、このTensorをNumPy配列に変換します。これは、多くの深層学習フレームワークのモデルがNumPy配列を直接入力として期待するために行われます。
以降の処理のための型変換(Tensor型からNumPy配列型)のようです。
np.array([...])
変換されたNumPy配列を、さらにNumPyの配列に包んでいます。これは、多くのモデルがバッチ処理に対応しており、入力データが4次元テンソル(バッチサイズ、高さ、幅、チャンネル)を期待するためです。
ここでは、1枚の画像をバッチサイズ1で処理するため、リストの中にNumPy配列を1つ入れる形になっています。
「model.predict()」の引数が複数分の画像データの処理を想定したNumPy配列型となっているため、先の処理で取得された1画像分のNumPy配列をさらにNumPy配列で包んでいるということですね。
model.predict([...])[0]
入力されたデータに対して、モデルが学習した内容に基づいて出力(予測値)を生成します。
model.predict() の出力は、一般的にバッチサイズの分の予測結果がリストまたはNumPy配列で返されます。
ここでは、バッチサイズが1なので、0番目の要素(つまり、入力した1枚の画像に対応する予測結果)を取り出しています。
上記がエンベディングの本体であり、画像データを特徴ベクトル化しています。
先に触れたように「model.predict()」は複数画像分の同時処理を想定しているため出力も複数画像分(NumPy配列)となるのですが、今回は対象画像が1つなので、無条件のその先頭要素を取得している訳です。
結果確認
先の処理の最後の部分で、取得された特徴ベクトルの内容を出力しています。
実際に表示された内容は以下のとおりです。
[0.29769936 0.26892176 0.01304864 ... 1.856004 0.37573996 1.16986 ]
(2048,)
float32
2048次元のベクトルとして出力されるようです。
ベクトルの各要素の型はfloat32でした。
まとめ
とりあえず、画像データのベクトル化ができるようになりました。
今回の検証に利用した画像データは480×690(46KB)でした。
サイズ的には比較的小さめな画像かと思いますが、それでも処理時間に5〜6秒(エンベディング処理自体は2秒程度)要しています。
途中で触れたように、今回の検証で使用したTensorFlowはCPU版であるため、処理性能は最初から考慮していませんでしたが、少なくとも実運用としては少々厳しい待ち時間かと思います。
この辺はGPU版に期待したいところですが、そもそも今までWebサーバ等で使用してきたHWではGPUは搭載されていないと思われ、搭載しているHWを選択する場合は当然ながらコストが高くなる可能性大です。
この辺の問題は今後の課題にしたいと思います。
また、今回の処理では深層学習モデルとして「ResNet50」なるものを使用しましたが、その他にも「VGG」や「EfficientNet」などもあるようで、画像データの性質や要件(処理速度、精度)で選ぶべきモデルが変わってくるようです。
なお、適切なモデルを選択するためには、結局のところ実験してみるしかないようなので、この辺も今後の課題としておきます。