はじめに
前回の記事で、 Livebook 上にノーコードで顔認証を実装しました
ただし、この方法だと毎回顔写真を選択し、顔の特徴量を計算することになります
比較対象となる顔の特徴量をデータベースに入れておけば、そんな必要はありません
というわけで、ベクトル(テンソル)に特化したベクトルデータベース Pinecone を使って、顔特徴量の保存、検索を実行します
公式サイトに "Long-Term Memory for AI" とあるように、 AI の入出力として使われるベクトルを保存、検索するためのデータベースです
今回も Livebook で実装します
実装したノートブックはこちら
セットアップ
必要なモジュールをインストールします
Mix.install(
[
{:kino, "~> 0.9"},
{:evision, github: "cocoa-xu/evision", branch: "main"},
{:pinecone, "~> 0.1"},
{:jason, "~> 1.4"}
],
system_env: [
{"EVISION_PREFER_PRECOMPILED", "false"}
]
)
evision は最新版が未リリースなので、 GitHub からコードを取得し、ビルドします
pinecone は Pinecone 用の Elixir モジュールですが、まだ荒削りな状態で、必要なものが揃ってはいませんが、とりあえずこれを使っていきます
jason は Pinecone API へのリクエストを JSON 形式に変換するために使います
画像の準備
今回の例では "/home/livebook/evision/test-images/" 配下に .jpg と .png の画像ファイルを用意しました
image_files = Path.wildcard("/home/livebook/evision/test-images/*.{jpg,png}")
とりあえず顔写真を表示してみます
images =
image_files
|> Enum.map(fn image_file ->
Evision.imread(image_file)
end)
Kino.Layout.grid(images, columns: 4)
左上から7枚は AI生成画像です
「メガネをかけた日本人エンジニア」など、私っぽくなるように生成したものから、「日本人議員」で生成した女性の顔まで、色々作ってみました
8枚目は生身の人間で、実在する私の後輩です(記事への掲載について本人了承済)
9枚目以降の8枚はすべて私です
色々な角度だったり、メガネを外していたり、マスクをしていたり、プリクラで美顔加工されていたり、AIにイラスト化されていたりします
今回使用する SFace では、顔の特徴量を長さ 128 のベクトルで表します
SFace は特徴量ベクトル間のコサイン類似度が、同一人物なら 1 に近く、別人なら -1 に近くなるよう学習しています
うまく個人識別できていれば、下段2つの8枚(同一人物)は特徴量が近く = ベクトル間のコサイン類似度が 1 に近くなるはずです
顔特徴量の抽出
顔特徴量の抽出ように SFace 、顔検出用に YuNet のモデルを読み込みます
recognizer =
Evision.Zoo.FaceRecognition.SFace.init(:default_model,
backend: Evision.Constant.cv_DNN_BACKEND_OPENCV(),
target: Evision.Constant.cv_DNN_TARGET_CPU(),
distance_type: :cosine_similarity,
cosine_threshold: 0.363,
l2_norm_threshold: 1.128
)
detector =
Evision.Zoo.FaceDetection.YuNet.init(:default_model,
backend: Evision.Constant.cv_DNN_BACKEND_OPENCV(),
target: Evision.Constant.cv_DNN_TARGET_CPU(),
nms_threshold: 0.3,
conf_threshold: 0.9,
top_k: 5
)
各画像について、以下の処理を実行します
- A. 顔を検出する
- B. A の結果から顔の座標情報を取得する
- C. 顔画像と B を使い、顔特徴量を取得する
- D. 顔検出結果を視覚化する
- C と D を配列に格納する
D(顔検出結果の視覚化)を表示します
[feature_list, visualized_list] =
images
|> Enum.reduce([[], []], fn image, [feature_acc, visualized_acc] ->
results = Evision.Zoo.FaceDetection.YuNet.infer(detector, image)
bbox = Evision.Mat.to_nx(results, Nx.BinaryBackend)[0][0..-2//1]
feature =
recognizer
|> Evision.Zoo.FaceRecognition.SFace.infer(image, bbox)
|> Evision.Mat.to_nx()
|> Evision.Mat.from_nx()
visualized = Evision.Zoo.FaceDetection.YuNet.visualize(image, results[0])
[[feature | feature_acc], [visualized | visualized_acc]]
end)
|> Enum.map(&Enum.reverse/1)
Kino.Layout.grid(visualized_list, columns: 4)
四角形が顔の検出範囲、四角形の中の点は両目、鼻、口角の検出位置です
特徴量の中身を見てみましょう
feature_list
実行結果は以下のようになります
[
%Evision.Mat{
channels: 1,
dims: 2,
type: {:f, 32},
raw_type: 5,
shape: {1, 128},
ref: #Reference<0.2446145276.1381105673.600>
},
...
]
形が 1 * 128 で、 32 ビット浮動小数の行列になっています
先頭の行列をテンソルとして表示してみます
feature_list
|> hd()
|> Evision.Mat.to_nx()
結果は以下のようになります
#Nx.Tensor<
f32[1][128]
Evision.Backend
[
[0.2478046417236328, -0.07941834628582001, -1.5164910554885864, 0.5658660531044006, 0.32173722982406616, 1.0506480932235718, -0.6378516554832458, 1.7099213600158691, 1.3846274614334106, -0.5175538659095764, -0.2464766800403595, 0.4892995059490204, -0.9083938002586365, -0.40185821056365967, 1.257846713066101, -1.329054832458496, -0.6721335053443909, 0.898859441280365, -0.6860005855560303, -1.037187933921814, 0.4402664005756378, 1.9445024728775024, 0.8437008857727051, -0.540610671043396, -0.2207109034061432, 1.9215633869171143, 1.0295889377593994, 0.9870990514755249, 0.8276092410087585, 0.8378713726997375, 0.8952299356460571, 3.1368656158447266, 1.896082878112793, -0.5408202409744263, 1.3720626831054688, -0.4063827097415924, 0.41860976815223694, 0.7061173915863037, 0.970976710319519, 1.5799314975738525, 0.3332902193069458, 2.4629318714141846, -1.047968864440918, -1.2429616451263428, 0.19454512000083923, 0.2729840576648712, 2.6603450775146484, -0.1787053644657135, -0.800810694694519, 0.013038262724876404, ...]
]
>
1 * 128 のテンソルに小数の値が入っていますね
試しにローカルで顔特徴量間のコサイン類似度を計算してみましょう
下から2段目の左端と、最下段の右端(どちらとも私)を比較してみます
Evision.Zoo.FaceRecognition.SFace.match_feature(
recognizer,
Enum.at(feature_list, 8),
Enum.at(feature_list, 15)
)
実行結果は以下のようになります
%{matched: true, measure: "cosine_score", retval: 0.8260765364864255}
コサイン類似度は 0.826 で非常に高く、 matched: true
同一人物、という判定結果を出し増田
evision ではコサイン類似度の閾値 0.363 で同一人物かどうかを判定しています
下から2段目の左端と、最上段の左端(別人)を比較してみます
Evision.Zoo.FaceRecognition.SFace.match_feature(
recognizer,
Enum.at(feature_list, 8),
Enum.at(feature_list, 0)
)
実行結果は以下のようになります
%{matched: false, measure: "cosine_score", retval: -0.1544433785079491}
コサイン類似度は -0.154 で負にになっており、 matched: false
で別人判定です
顔特徴量は正しく取得できたようです
顔特徴量を Pinecone に登録できるベクトルの形式に変換します
vectors =
feature_list
|> Enum.zip(image_files)
|> Enum.map(fn {feature, image_file} ->
values =
feature
|> Evision.Mat.to_nx(Nx.BinaryBackend)
|> Nx.flatten()
|> Nx.to_list()
%{
values: values,
id: image_file
}
end)
実行結果は以下のようになります
[
%{
id: "/home/livebook/evision/test-images/others-000.jpg",
values: [0.2478046417236328, -0.07941834628582001, -1.5164910554885864, 0.5658660531044006,
0.32173722982406616, 1.0506480932235718, -0.6378516554832458, 1.7099213600158691,
1.3846274614334106, -0.5175538659095764, -0.2464766800403595, 0.4892995059490204,
-0.9083938002586365, -0.40185821056365967, 1.257846713066101, -1.329054832458496,
-0.6721335053443909, 0.898859441280365, -0.6860005855560303, -1.037187933921814,
0.4402664005756378, 1.9445024728775024, 0.8437008857727051, -0.540610671043396,
-0.2207109034061432, 1.9215633869171143, 1.0295889377593994, 0.9870990514755249,
0.8276092410087585, 0.8378713726997375, 0.8952299356460571, 3.1368656158447266,
1.896082878112793, -0.5408202409744263, 1.3720626831054688, -0.4063827097415924,
0.41860976815223694, 0.7061173915863037, 0.970976710319519, 1.5799314975738525,
0.3332902193069458, 2.4629318714141846, -1.047968864440918, -1.2429616451263428,
0.19454512000083923, 0.2729840576648712, 2.6603450775146484, ...]
},
...
]
id に画像ファイルのパス、 values にベクトルを配列化したものを入れています
インデックスの作成
Pinecone のアカウントを持っていない場合、とりあえず無料で始められるので、トップページの右上 "Sign Up Free" からサインアップしてください
メールアドレス等でサインアップすると、初期化中の画面が表示されるので、しばらく待ちます
以下のような表示になれば使い始めることができます
左メニュー "API Keys" を開いておきます
このままブラウザコンソールから操作してもいいのですが、せっかくなので Elixir で操作していきます
認証情報を入力するためのテキストインプットを作ります
Pinecone コンソールの "API Keys" から、 Value の値を api_key_input に、 Environment の値を environment_input に入力します
api_key_input = Kino.Input.password("API_KEY")
environment_input = Kino.Input.text("ENVIRONMENT")
入力した値を変数に読み込みます
api_key = Kino.Input.read(api_key_input)
environment = Kino.Input.read(environment_input)
インデックス操作の機能が Elixir の pinecone モジュール内に存在しないため、自作します
defmodule Pinecone.Controller do
def new(opts) do
environment = Keyword.get(opts, :environment)
api_key = Keyword.get(opts, :api_key)
middleware = [
{Tesla.Middleware.BaseUrl, "https://controller.#{environment}.pinecone.io"},
Tesla.Middleware.JSON,
{Tesla.Middleware.Headers, [{"api-key", api_key}]}
]
Tesla.client(middleware)
end
def create_index(client, opts) do
params = Enum.into(opts, %{})
client
|> Tesla.post("/databases", params)
|> handle_response()
end
def list_indexes(client) do
client
|> Tesla.get("/databases")
|> handle_response()
end
def describe_index(client, name) do
client
|> Tesla.get("/databases/#{name}")
|> handle_response()
end
def delete_index(client, name) do
client
|> Tesla.delete("/databases/#{name}")
|> handle_response()
end
def handle_response(resp, opts \\ [])
def handle_response({:error, _} = err, _opts), do: err
def handle_response({:ok, %Tesla.Env{status: status, body: body}}, _opts) when status <= 400 do
{:ok, body}
end
def handle_response({:ok, resp}, _opts), do: {:error, resp}
end
インデックス操作用の controller
を用意します
そのまま実行すると controller
の中に含むAPIキーの値が結果の表示されてしまうので、セルの最後に "dummy"
を入れています
controller =
Pinecone.Controller.new(
environment: environment,
api_key: api_key
)
"dummy"
今回は顔検索用インデックスなので、インデックス名を "face-search" にします
index_name = "face-search"
以下のようにパラメータを指定し、インデックスを作成します
- name: インデックス名
- dimension: ベクトルのサイズ(今回の顔特徴量は128)
- metric: 距離・類似度の計算方法(今回はコサイン類似度)
- pod_type: Pinecone でデータを格納するハードウェアのタイプを指定します(今回は無料プランで利用可能な s1 = ストレージ効率重視を使います)
Pinecone.Controller.create_index(controller,
name: index_name,
dimension: 128,
metric: "cosine",
pod_type: "s1"
)
実行結果は以下のようになります
{:ok, ""}
ただし、この状態はインデックスの作成を開始しただけでまだ使えません
インデックスの一覧を取得してみます
controller
|> Pinecone.Controller.list_indexes()
|> elem(1)
実行結果は以下のようになります
["face-search"]
インデックスの詳細を取得します
controller
|> Pinecone.Controller.describe_index(index_name)
|> elem(1)
実行結果は以下のようになります
%{
"database" => %{
"dimension" => 128,
"metric" => "cosine",
"name" => "face-search",
"pods" => 1,
"replicas" => 1,
"shards" => 1
},
"status" => %{
"crashed" => [],
"host" => "face-search-1ee3389.svc.us-west1-gcp-free.pinecone.io",
"port" => 433,
"ready" => true,
"state" => "Ready",
"waiting" => []
}
}
"state" => "Ready"
になるまでは使えないので、それまでは待ちます
API 呼び出しに使うため、プロジェクトのIDを取得しておきます
project =
controller
|> Pinecone.Controller.describe_index(index_name)
|> elem(1)
|> Map.get("status")
|> Map.get("host")
|> String.split(".")
|> Enum.at(0)
|> String.replace("#{index_name}-", "")
ベクトルの登録
ベクトル操作用のクライアントを作成します
このときも APIキー が結果に表示されないよう、最後に "dummy"
を出しておきます
client =
Pinecone.Client.new(
environment: environment,
api_key: api_key,
project: project,
index: index_name
)
"dummy"
準備しておいた vectors
を登録します
Pinecone では upsert なので、同じ ID のベクトルを登録した場合、必ず上書きされる点に注意してください
Pinecone.Vector.upsert(client, %{vectors: vectors})
実行結果は以下のようになります
{:ok, %{"upsertedCount" => 16}}
16個の顔特徴量が登録できました
ベクトルの検索
私の顔を検索してみます
vectors
から インデックス 8 の顔特徴量を取得します
vector =
vectors
|> Enum.at(8)
|> Map.get(:values)
この顔特徴量に近い上位10個の顔特徴量を取得します
- topK: 上位何個まで取得するか
- vector: 検索するベクトル(今回は顔特徴量)
matches =
client
|> Pinecone.Vector.query(%{topK: 10, vector: vector})
|> elem(1)
|> Map.get("matches")
実行結果は以下のようになります
[
%{"id" => "/home/livebook/evision/test-images/ryo-000.png", "score" => 1, "values" => []},
%{
"id" => "/home/livebook/evision/test-images/ryo-007.jpg",
"score" => 0.826076448,
"values" => []
},
%{
"id" => "/home/livebook/evision/test-images/ryo-003.jpg",
"score" => 0.560545802,
"values" => []
},
...
}
score
にコサイン類似度が入っており、 score
の高い順に10個返ってきました
id
を画像ファイルのパスにしているので、そのまま読み込んで表示してみます
matches
|> Enum.map(fn match ->
[
Kino.Markdown.new("#{match["score"]}"),
Evision.imread(match["id"])
]
|> Kino.Layout.grid()
end)
|> Kino.Layout.grid(columns: 4)
各画像の上にスコアも表示しています
左上、トップの画像は検索した顔と同じ顔です
全く同じ顔なので、スコアは 1 になっています
2番目はスコア 0.826 で、確かに同一人物です(太って髪が伸びて変な表情になっていますが)
3番目はメガネを外した同一人物、4番目、5番目も私です
6番目についに別人(AIが生成した顔画像)が出てきました
スコアは 0.310 なので、閾値が 0.363 なら別人判定ですね
7番目、8番目、10番目は加工されたりマスクをしたりした私ですが、そもそも顔検出位置がずれていたこともあってこれは仕方ないですね
9番目は別人(実在する私の後輩)で、コサイン類似度は 0.178 でした
判定結果の正しさは SFace や YuNet の問題なので置いておくとして、確かに Pinecone でベクトル検索が実行できました
インデックスの削除
最後にインデックスを削除しておきます
Pinecone.Controller.delete_index(controller, index_name)
まとめ
Pinecone を使うことで、ベクトルの保存、検索ができることを確認しました
今回実装したように顔認証用のデータベースとして使うことができます
また、テキストをAIでベクトル化することで、非常に柔軟な文書検索を構築することも可能です
公式の Python 用チュートリアルから簡単に体験できます
もちろん、今回やったように Elixir でも簡単に使えたので、 Elixir でベクトルを扱うときには使ってみましょう