LoginSignup
47
20
記事投稿キャンペーン 「AI、機械学習」

顔を PostgreSQL に登録して SQL で似ている顔(同一人物)を探す

Last updated at Posted at 2023-11-07

はじめに

前回の記事では、画像の 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)

スクリーンショット 2023-11-02 10.19.30.png

左上から順に 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)

スクリーンショット 2023-11-02 10.23.54.png

顔特徴量を一つ確認してみましょう

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 をクリックしてください

スクリーンショット 2023-10-31 11.58.19.png

表示されたモーダルの Name に DB_PASSWORD 、 Value にパスワードの値を入力し、 + Add をクリックします

スクリーンショット 2023-10-31 12.00.02.png

これによって、 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)

スクリーンショット 2023-11-02 10.51.57.png

見事に私の顔ばかりが並びましたね

さすがに「プリクラに激盛りされた顔」と「AIにイラスト化された顔」は別人判定ですが

まとめ

拡張機能 pgvector を PostgreSQL に追加することで、 SQL による同一人物の検索が実装できました

Livebook を使うことで、同一人物と判定した画像を簡単に並べて表示でき、確認作業も楽になります

47
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
47
20