はじめに
前回の記事では、画像の dHash をビット配列としてデータベース PostgreSQL に登録し、 SQL で類似画像を取得しました
また、以前の記事で画像内の顔特徴量をベクトルデータベース Pinecone に登録し、似ている顔を取得しました
さらに、 PostgreSQL は拡張機能 pgvector によってベクトル型を扱うことができます
というわけで、これらの組み合わせを Livebook で実装します
- 対象となる画像から evision で顔を検出し、顔の特徴量を取得する
- 顔の特徴量をベクトルとして PostgreSQL に登録する
- SQL で似ている顔の組み合わせを取得する
Livebook と PostgreSQL をコンテナで立ち上げる手順については以下の記事を参考にしてください
データベース操作には Ecto を使用します
Livebook 上での Ecto 使用については以下の記事を参考にしてください
本記事では Elixir + Livebook で実装していますが、 SQL 自体は PostgreSQL で使えるものなので、 Python などで同じことを考えている方も是非参考にしてください
SQL だけ見たい方はこちら
実装したノートブックはこちら
環境構築
そのままの PostgreSQL ではベクトル型を使えないため、 pgvector のパッケージをインストールしておく必要があります
Docker コンテナの場合、 Dockerfile に以下のようにインストールコマンドを追加します
FROM postgres:15.3-bullseye
# pgvector のインストール
RUN apt-get update \
&& apt-get install --no-install-recommends --no-install-suggests -y postgresql-15-pgvector \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
macOS の場合は Homebrew によるインストールが可能です
brew install pgvector
拡張機能の PostgreSQL へのインストールは後で Livebook から実行します
セットアップ
必要なモジュールをインストールします
今回は顔検出、顔特徴量抽出に必要なものと、 DB 操作に必要なものをインストールしています
evision は最新版を使うため、 GitHub からコードを取得してビルドしています
そのため、初回インストールには数分を要します
Mix.install(
[
{:ecto, "~> 3.10"},
{:ecto_sql, "~> 3.10"},
{:jason, "~> 1.4"},
{:kino, "~> 0.11"},
{:postgrex, "~> 0.17.3"},
{:pgvector, "~> 0.2.0"},
{:evision, github: "cocoa-xu/evision", branch: "main"}
],
system_env: [
{"EVISION_PREFER_PRECOMPILED", "false"}
]
)
画像の準備
今回は /home/livebook/evision/test-images/
ディレクトリー配下に私の顔と AI に生成させた別人の顔を複数用意しています
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)
左上から順に index 0 から 7 は別人の顔、 8 から 15 は私の顔です
顔特徴量の取得
各画像から顔を検出し、その顔の特徴量を取得します
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.8,
top_k: 5
)
[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
|> hd()
|> Evision.Mat.to_nx()
実行結果
#Nx.Tensor<
f32[1][128]
Evision.Backend
[
[0.44686225056648254, -0.18176832795143127, -1.4439196586608887, 0.857354998588562, 0.13995742797851562, 0.7470818758010864, -1.0087882280349731, 1.6348930597305298, 1.2836309671401978, -0.3876262903213501, -0.2364024817943573, 0.18307186663150787, -0.9165143966674805, -0.7121557593345642, 1.2633715867996216, -2.056678295135498, -0.6618753671646118, 0.795778214931488, -0.5795799493789673, -1.2062581777572632, 0.4097854793071747, 1.9837095737457275, 0.9994585514068604, -0.7853516936302185, -0.1551920771598816, 1.879717469215393, 1.1089434623718262, 1.1372482776641846, 0.7654930949211121, 0.8863930106163025, 0.9100679159164429, 3.0138211250305176, 1.3833198547363281, -0.21598772704601288, 1.6673616170883179, -0.7787826061248779, -0.0989885926246643, 0.7702988386154175, 0.9240713119506836, 1.575907826423645, 0.23002350330352783, 2.8379931449890137, -1.216612458229065, -1.2611130475997925, 0.128706693649292, -0.3130177855491638, 2.0949244499206543, -0.5832861661911011, -0.9683513045310974, -0.2483583390712738, ...]
]
>
顔特徴量の型は f32[1][128] で、浮動小数の 1 * 128 のテンソル(ベクトル)になっています
evision で顔特徴量同士の類似度を計算します
結果はコサインスコア = コサイン類似度 = -1 (別人)から 1 (同一人物)の間の値と、閾値による判定結果で返ってきます
Evision.Zoo.FaceRecognition.SFace.match_feature(
recognizer,
Enum.at(feature_list, 8),
Enum.at(feature_list, 15)
)
実行結果
%{matched: true, retval: 0.8236624454884804, measure: "cosine_score"}
index 8 と 15 はどちらも私の顔なので、類似度 0.82 で同一人物として正しく判定されています
Evision.Zoo.FaceRecognition.SFace.match_feature(
recognizer,
Enum.at(feature_list, 8),
Enum.at(feature_list, 0)
)
実行結果
%{matched: false, retval: -0.14119371311062423, measure: "cosine_score"}
index 8 と 0 は私の顔と別人の顔なので、類似度 -0.14 で別人として正しく判定されています
全ての顔特徴量を DB 登録用に整形しておきます
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()
%{
embedding: values,
file_path: image_file
}
end)
DB の準備
ベクトル型の追加
Ecto で PostgreSQL のベクトル型を使用するため、型を拡張しておきます
Postgrex.Types.define(
ExtendedTypes,
[Pgvector.Extensions.Vector] ++ Ecto.Adapters.Postgres.extensions(),
[]
)
DB 接続用モジュールの定義
Ecto で PostgreSQL に接続するためのモジュールを用意します
defmodule Repo do
use Ecto.Repo,
otp_app: :my_notebook,
adapter: Ecto.Adapters.Postgres
end
秘密情報の設定
DB接続のパスワードは秘密情報なので、 Livebook の秘密情報に登録しておきます
Livebook の左メニュー南京錠🔒のアイコンをクリックすると、 SECRETS のメニューが表示されます
+ New secrets
をクリックしてください
表示されたモーダルの Name に DB_PASSWORD
、 Value にパスワードの値を入力し、 + Add
をクリックします
これによって、 DB のパスワードは LB_DB_PASSWORD
という環境変数に格納されました
Repo を子プロセスとして起動し、 DB に接続します
types: ExtendedTypes
でベクトル型を拡張するように指定しています
opts = [
hostname: "postgres_for_livebook",
port: 5432,
username: "postgres",
password: System.fetch_env!("LB_DB_PASSWORD"),
database: "postgres",
types: ExtendedTypes
]
Kino.start_child({Repo, opts})
{:ok, #PID<0.2572.0>}
というような結果が表示されれば接続できています
拡張機能の追加
Ecto の Migration を利用して拡張機能を追加します
defmodule Migrations.CreateVectorExtension do
use Ecto.Migration
def up do
execute("CREATE EXTENSION IF NOT EXISTS vector")
end
def down do
execute("DROP EXTENSION vector")
end
end
Migration を実行する場合、第2引数が他の Migration と重複しないように注意してください
Ecto.Migrator.up(Repo, 21, Migrations.CreateVectorExtension)
テーブルの作成
face テーブル作成の Migration を定義します
顔特徴量は 128 次元のベクトル型を使用します
defmodule Migrations.CreateFaceTable do
use Ecto.Migration
def change do
create table(:face) do
add(:file_path, :string)
add(:embedding, :vector, size: 128)
end
end
end
定義した Migration を適用します
Ecto.Migrator.up(Repo, 31, Migrations.CreateFaceTable)
DB への登録
DB 操作用のモジュールを定義します
defmodule Face do
use Ecto.Schema
schema "face" do
field(:file_path, :string)
field(:embedding, Pgvector.Ecto.Vector)
end
end
画像から取得した顔特徴量を全て追加します
Repo.insert_all(Face, vectors)
実行結果
{16, nil}
16 件全て登録できました
データを確認してみましょう
Repo.all(Face)
実行結果
[
%Face{
__meta__: #Ecto.Schema.Metadata<:loaded, "face">,
id: 1,
file_path: "/home/livebook/evision/test-images/others-000.jpg",
embedding: Pgvector.new([0.44686225056648254, -0.18176832795143127, -1.4439196586608887,
0.857354998588562, 0.13995742797851562, 0.7470818758010864, -1.0087882280349731,
1.6348930597305298, 1.2836309671401978, -0.3876262903213501, -0.2364024817943573,
0.18307186663150787, -0.9165143966674805, -0.7121557593345642, 1.2633715867996216,
-2.056678295135498, -0.6618753671646118, 0.795778214931488, -0.5795799493789673,
-1.2062581777572632, 0.4097854793071747, 1.9837095737457275, 0.9994585514068604,
-0.7853516936302185, -0.1551920771598816, 1.879717469215393, 1.1089434623718262,
1.1372482776641846, 0.7654930949211121, 0.8863930106163025, 0.9100679159164429,
3.0138211250305176, 1.3833198547363281, -0.21598772704601288, 1.6673616170883179,
-0.7787826061248779, -0.0989885926246643, 0.7702988386154175, 0.9240713119506836,
1.575907826423645, 0.23002350330352783, 2.8379931449890137, -1.216612458229065,
-1.2611130475997925, 0.128706693649292, ...])
},
...
]
顔特徴量が embedding 列にベクトル型で格納されていることが分かります
同一人物検索用 SQL の実行
いよいよ同一人物検索用 SQL を実行します
query =
"""
SELECT
src.file_path as src_file_path,
dst.file_path as dst_file_path,
src.embedding <=> dst.embedding as cos_distance
FROM
face AS src
INNER JOIN
face AS dst
ON
src.id < dst.id
AND
src.embedding <=> dst.embedding < 0.5
ORDER BY
cos_distance ASC
"""
{:ok, result} = Ecto.Adapters.SQL.query(Repo, query, [])
実行結果
{:ok,
%Postgrex.Result{
command: :select,
columns: ["src_file_path", "dst_file_path", "cos_distance"],
rows: [
["/home/livebook/evision/test-images/ryo-000.png",
"/home/livebook/evision/test-images/ryo-007.jpg", 0.17633757221380875],
["/home/livebook/evision/test-images/ryo-003.jpg",
"/home/livebook/evision/test-images/ryo-007.jpg", 0.45273066093361714],
["/home/livebook/evision/test-images/ryo-002.jpg",
"/home/livebook/evision/test-images/ryo-006.jpg", 0.4527948502987734],
["/home/livebook/evision/test-images/ryo-000.png",
"/home/livebook/evision/test-images/ryo-003.jpg", 0.4853983398291313],
["/home/livebook/evision/test-images/ryo-000.png",
"/home/livebook/evision/test-images/ryo-001.jpg", 0.4866601281479783],
["/home/livebook/evision/test-images/ryo-000.png",
"/home/livebook/evision/test-images/ryo-002.jpg", 0.49148169389351226],
["/home/livebook/evision/test-images/ryo-003.jpg",
"/home/livebook/evision/test-images/ryo-006.jpg", 0.49755117701050044]
],
num_rows: 7,
connection_id: 128,
messages: []
}}
全てファイル名のプレフィックスが ryo-
になっているので、私の顔同士が選ばれていることが分かります
ポイントを確認してみましょう
自己結合
INNER JOIN で同じテーブル同士を結合することで、すべての組み合わせを探索しています
同じ特徴量同士の比較をスキップし、順番を入れ替えた組み合わせもスキップするため、結合条件は src.id < dst.id
です
...
face AS src
INNER JOIN
face AS dst
ON
src.id < dst.id
...
コサイン距離の計算
SQL では A <=> B
でコサイン距離を求めています
コサイン類似度と逆で、小さいほど同一顔として判定するため、 0.5 未満のものを取得するようにしています
その他、 pgvector では以下の演算子を利用できます
演算子 | 演算内容 |
---|---|
+ |
ベクトルの和 |
- |
ベクトルの差 |
* |
ベクトルの積 |
<-> |
ユークリッド距離 |
<#> |
内積 |
<=> |
コサイン距離 |
結果の視覚化
同一人物と判定された画像同士を並べて、確かに同一人物か確認しましょう
result.rows
|> Enum.map(fn [src_file_path, dst_file_path, distance] ->
src_img = Evision.imread(src_file_path)
dst_img = Evision.imread(dst_file_path)
[
distance,
Kino.Layout.grid([src_img, dst_img], columns: 2)
]
|> Kino.Layout.grid(columns: 1)
end)
|> Kino.Layout.grid(columns: 4)
見事に私の顔ばかりが並びましたね
さすがに「プリクラに激盛りされた顔」と「AIにイラスト化された顔」は別人判定ですが
まとめ
拡張機能 pgvector を PostgreSQL に追加することで、 SQL による同一人物の検索が実装できました
Livebook を使うことで、同一人物と判定した画像を簡単に並べて表示でき、確認作業も楽になります