Home About Contact
TensorFlow , Keras

Auto Encoder の潜在ベクトルを使った画像検索

00007

Auto Encoder は、 Encoder と Decoder の2つのネットワークを使って、 入力画像から出力画像を生成するものです。 このとき出力画像を入力画像と一致するようにネットワークをトレーニングします。 そうやって入力画像からそれとそっくりな出力画像を生成できるようになったら 中間生成物である Encoder の出力情報(これを潜在ベクトルと呼ぶ) を利用することを考えます。

Encoder の出力情報(潜在ベクトル)は、入力のそれより少なくなるようにモデルを設計しているので、 潜在ベクトルは入力画像の特徴を凝縮した形で表現されていると考えることができます。 ならば、潜在ベクトルが似ている画像は、 元の画像も似た画像に違いない。 この性質を利用して、潜在ベクトルが似た画像を探すことで、 画像を分類したり・画像を検索したりすることが実現できる、という発想です。

今回は顔コレデータセットを使って、これを試してみます。

顔コレデータセット入手方法は前回のエントリで書いたの省きます。 現在のディレクトリの ./kaokore/images_256/ に 9683 件の顔画像が存在していることとして話を進めます。

環境

大雑把ですが、以下のようなツールとバージョンで試しています。

numpy==1.26.0
tensorflow==2.14.0
Pillow==10.0.1
annoy==1.17.3

Python のバージョンは 3.8 か 3.9 かそのあたりです。

リサイズ

これからつくる Auto Encoder への入力画像サイズを 56x56 サイズとして扱いたい。

顔画像として 256x256 サイズの画像がダウンロードされているはずですが、 以下のコードで調べてみると 200x200 など 異なるサイズも含まれているようです。

from glob import glob
from PIL import Image

for input_file in glob("kaokore/images_256/*.jpg")[0:100]:
    image = Image.open(input_file)
    print(image.size)

次のコードで 56x56 サイズに統一します。 結果の画像は ./kaokore/images_56/ 以下に保存します。

import os
from glob import glob
import numpy as np
import tensorflow as tf
from PIL import Image

export_dir = "kaokore/images_56"
os.makedirs(export_dir, exist_ok=True)

for input_file in glob("kaokore/images_256/*.jpg"):
    image_file   = tf.io.read_file(input_file)
    images = tf.image.decode_jpeg(image_file, channels=3)
    images_56x56 = tf.image.resize(images, size=(56, 56) )

    basename = os.path.basename(input_file)
    output_file = os.path.join(export_dir, basename)
    Image.fromarray(np.uint8(images_56x56)).convert("RGB").save(output_file)

モデルの用意

モデルで共通して使用するパラメータを定義。

image_size = 56
latent_dim = 128

中間で生成される潜在ベクトルの次元を 128 にしました。

encoder

エンコーダーモデルです。

encoder = Sequential([
    Input(shape=(image_size, image_size, 3), name="image_input"),
    Conv2D(32, 3, activation='relu', strides=2, padding='same'),
    Conv2D(32, 3, activation='relu', strides=2, padding='same'),
    Flatten(),
    Dense(latent_dim)
], name="encoder")

入力画像はカラーなので、それを考慮して 3 チャンネル使います。

_________________________________________________________________
Model: "encoder"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 conv2d (Conv2D)             (None, 28, 28, 32)        896       
 conv2d_1 (Conv2D)           (None, 14, 14, 32)        9248      
 flatten (Flatten)           (None, 6272)              0         
 dense (Dense)               (None, 128)               802944    
=================================================================
Total params: 813088 (3.10 MB)
Trainable params: 813088 (3.10 MB)
Non-trainable params: 0 (0.00 Byte)

decoder

デコーダーモデルです。

decoder = Sequential([
    Input(shape=(latent_dim,), name="d_input"),
    Dense(7*7*64, activation='relu'),
    Reshape((7, 7, 64)),
    Conv2DTranspose(32, 3, activation='relu', strides=2, padding='same'),
    Conv2DTranspose(32, 3, activation='relu', strides=2, padding='same'),
    Conv2DTranspose(3, 3,  activation='sigmoid', strides=2, padding='same')
], name="decoder")

なんやかんやして、最終的には元の画像と同じ (56, 56, 3) に戻るようにします。

_________________________________________________________________
Model: "decoder"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 dense_1 (Dense)             (None, 3136)              404544    
 reshape (Reshape)           (None, 7, 7, 64)          0         
 conv2d_transpose (Conv2DTr  (None, 14, 14, 32)        18464     
 anspose)                                                        
 conv2d_transpose_1 (Conv2D  (None, 28, 28, 32)        9248      
 Transpose)                                                      
 conv2d_transpose_2 (Conv2D  (None, 56, 56, 3)         867       
 Transpose)                                                      
=================================================================
Total params: 433123 (1.65 MB)
Trainable params: 433123 (1.65 MB)
Non-trainable params: 0 (0.00 Byte)

auto encoder (encoder + decoder)

最後にエンコーダーとデコーダーをつないでオートエンコーダーのモデルを完成させます。

encoder_inputs = Input(shape=(image_size, image_size, 3))
x = encoder(encoder_inputs)
decoder_outputs = decoder(x)
auto_encoder = Model(
    inputs=encoder_inputs,
    outputs=decoder_outputs,
    name="auto_encoder")

完成したオートエンコーダーモデル。

_________________________________________________________________
Model: "auto_encoder"
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 input_1 (InputLayer)        [(None, 56, 56, 3)]       0         
 encoder (Sequential)        (None, 128)               813088    
 decoder (Sequential)        (None, 56, 56, 3)         433123    
=================================================================
Total params: 1246211 (4.75 MB)
Trainable params: 1246211 (4.75 MB)
Non-trainable params: 0 (0.00 Byte)
_________________________________________________________________

(56, 56, 3) のテンソルを入力として、(56, 56, 3) のテンソルが出力されるようにできています。 中間で生成される潜在ベクトルは (128, ) になっています。

トレーニングを実施

今回の目的は、画像検索・・・つまり特定の顔画像からそれに類似した顔画像を潜在ベクトルを使って探すことができるかの確認なので、 9683件ある顔画像の前から 6000件 ([0:6000]) をトレーニングに使用することにします。 6000 以後 ([6000:]) の画像はトレーニングから除外し、検索実験のために残しておきます。

def to_tensor(image_path):
    image_file   = tf.io.read_file(image_path)
    image_tensor = tf.image.decode_jpeg(image_file, channels=3)
    return tf.cast(image_tensor, tf.float32) / 255.0

# トレーニングデータを用意
x_train = tf.stack( list( map(lambda it: to_tensor(it), glob('kaokore/images_56/*.jpg')[0:6000]) ) )

# トレーニング
auto_encoder.compile(optimizer="adam", loss="mse")
auto_encoder.fit(x_train, x_train, epochs=epochs, batch_size=batch_size)

# モデルを保存
auto_encoder.save("auto_encoder_model.keras")
encoder.save("encoder_model.keras")
decoder.save("decoder_model.keras")

トレーニング結果の確認

トレーニングしたあと、この Auto Encoder が意図通り入力画像から出力画像を復元できているのか確認します。 上段が入力画像で、下段がオートエンコーダーが生成した画像です。

some autoencoded kaokore images

ぼやけてはいます(下段)が、 それぞれ元画像がどれかの判別は明確につく程度にはうまく復元できています。 ここでは、類似画像を検索できるだけの特徴を捉えていれば目的を達成できるので、これでよいことにします。

類似画像の検索

トレーニングに含めなかった 6000 以降の顔画像を対象に検索を実行してみました。

最も左の画像が検索のもとになる画像で、 それに類似したと判定された画像がその右側にある 12枚の画像です。 12枚の画像は類似度の高い順に並べています。

潜在ベクトルの類似性計算には annoy を使いました。(類似性計算のメトリクスは angular を使用。)
AnnoyIndex(f, metric) returns a new index that's read-write and stores vector of f dimensions. Metric can be "angular", "euclidean", "manhattan", "hamming", or "dot".

0

12

2

7

8

10

15

25

遠目から目を細めて眺めた限りでは確かに似た雰囲気の画像を検索できています。 とくに、色を考慮してトレーニングしたからなのか、色味が近いものが類似画像として検索されている気がします。

しかし以下の例のように人の顔でない顔に対して人の顔が類似画像として列挙されてしまいました。 オートエンコーダーの性質を考えると色味や構図が似ていれば似た画像と判定されるのは致し方ないかもしれません。

画像には性別や身分の属性ラベルがあるのだから、コレを教師データして加味することはできないものだろうか?

3

もし「髪の長い女の人を検索している」というように考えると、男の人も(類似画像として)検索されていて明らかに意図通りの結果ではない。

1

なんの前提知識も与えないで機械的に類似画像を見つけるというのはある意味すごいことですが、 「似た画像」と一言でいっても、たとえば「貴族の顔」という類似画像を見つけ出したい、とか、 「妖怪の顔」を探したいなどといった意味ベースの検索は難しいです。 オートエンコーダーの仕組みから考えて、これは見た目の似た画像を探すしくみなのでそこは致し方ない。

追伸

その後、潜在ベクターをより大きな値に変更したり、グレースケールに前処理する、トレーニング epoch 数をかなり増やすなど いろいろ変更してより精度をあげてみた。

どのくらい精度があがったかというと、元画像をグレースケールしたもの(上段)とそれをこのオートエンコーダーで変換した画像(下段)を見てください。

improved version autoencodered face images

一瞥しただけでは上段と下段が同じ画像に見えるほどトレーニングされている。よく見ると少し下段がぼやけている。

グレースケール画像を使ってトレーニングしたので当然ですが、色味に依存してしまうことはなくなった。 また、冠や烏帽子などに特徴がある場合は以下のようにうまく類似画像を検索できています。

00007

00024

00290

00005

また、(女性の)髪型に特徴がある場合も烏帽子などと同様にうまく機能しているようです。

00012

00021

00028

00039

00088

00278

人ならざるもの(化身?)

00153

00226

00256

00271

00293

00310

00799

これらはうまく類似画像を引き出せていない例です。 そもそも検索しようとしている画像と類似した画像が存在していないのが原因かもしれません。

00170

00320

00534

坊主?でしょうか、特徴がなさそうにも思えましたが意外にうまく機能している。

00249

まとめ

教師なし学習(自己教師あり学習)で似た画像に分類したり検索できることがわかった。 写真(絵)から顔部分を特定する技術と一緒に使うことで、対象となる顔と似た画像を探すことができますね。 怖いですね。