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

## はじめに

前回の記事では、画像の dHash をビット配列としてデータベース PostgreSQL に登録し、 SQL で類似画像を取得しました

https://qiita.com/RyoWakabayashi/private/2735ffeacc14921c3e9e

また、以前の記事で画像内の顔特徴量をベクトルデータベース Pinecone に登録し、似ている顔を取得しました

https://qiita.com/RyoWakabayashi/items/323626d9a1b33fdb4ac9

さらに、 PostgreSQL は拡張機能 pgvector によってベクトル型を扱うことができます

https://github.com/pgvector/pgvector

というわけで、これらの組み合わせを Livebook で実装します

- 対象となる画像から evision で顔を検出し、顔の特徴量を取得する
- 顔の特徴量をベクトルとして PostgreSQL に登録する
- SQL で似ている顔の組み合わせを取得する

Livebook と PostgreSQL をコンテナで立ち上げる手順については以下の記事を参考にしてください

https://qiita.com/RyoWakabayashi/items/7f3e2c288259f6a0a955

データベース操作には Ecto を使用します
Livebook 上での Ecto 使用については以下の記事を参考にしてください

https://qiita.com/RyoWakabayashi/items/76f9248757a488ac251e

本記事では Elixir + Livebook で実装していますが、 SQL 自体は PostgreSQL で使えるものなので、 Python などで同じことを考えている方も是非参考にしてください

SQL　だけ見たい方は[こちら](#同一人物検索用-sql-の実行)

実装したノートブックはこちら

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/evision/face_recognition_with_db.livemd

## 環境構築

そのままの PostgreSQL ではベクトル型を使えないため、 pgvector のパッケージをインストールしておく必要があります

Docker コンテナの場合、 Dockerfile に以下のようにインストールコマンドを追加します

```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 によるインストールが可能です

```bash
brew install pgvector
```

拡張機能の PostgreSQL へのインストールは後で Livebook から実行します

## セットアップ

必要なモジュールをインストールします

今回は顔検出、顔特徴量抽出に必要なものと、 DB 操作に必要なものをインストールしています

evision は最新版を使うため、 GitHub からコードを取得してビルドしています
そのため、初回インストールには数分を要します

```elixir
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 に生成させた別人の顔を複数用意しています

```elixir
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7777032b-efe0-7e05-8919-6dee3fdff5c5.png)

左上から順に index 0 から 7 は別人の顔、 8 から 15 は私の顔です

## 顔特徴量の取得

各画像から顔を検出し、その顔の特徴量を取得します

```elixir
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/0548f6bf-90df-1d5f-4439-1bf9db596acd.png)

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

```elixir
feature_list
|> hd()
|> Evision.Mat.to_nx()
```

実行結果
```elixir
#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 （同一人物）の間の値と、閾値による判定結果で返ってきます

```elixir
Evision.Zoo.FaceRecognition.SFace.match_feature(
  recognizer,
  Enum.at(feature_list, 8),
  Enum.at(feature_list, 15)
)
```

実行結果
```elixir
%{matched: true, retval: 0.8236624454884804, measure: "cosine_score"}
```

index 8 と 15 はどちらも私の顔なので、類似度 0.82 で同一人物として正しく判定されています

```elixir
Evision.Zoo.FaceRecognition.SFace.match_feature(
  recognizer,
  Enum.at(feature_list, 8),
  Enum.at(feature_list, 0)
)
```

実行結果
```elixir
%{matched: false, retval: -0.14119371311062423, measure: "cosine_score"}
```

index 8 と 0 は私の顔と別人の顔なので、類似度 -0.14 で別人として正しく判定されています

全ての顔特徴量を DB 登録用に整形しておきます

```elixir
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 のベクトル型を使用するため、型を拡張しておきます

```elixir
Postgrex.Types.define(
  ExtendedTypes,
  [Pgvector.Extensions.Vector] ++ Ecto.Adapters.Postgres.extensions(),
  []
)
```

### DB 接続用モジュールの定義

Ecto で PostgreSQL に接続するためのモジュールを用意します

```elixir
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/6c7fb00a-75b0-b38e-e06c-4c50c5e448ce.png)

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

![スクリーンショット 2023-10-31 12.00.02.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2050fdb2-fe66-b6b3-a08a-aa6c8e24b15b.png)

これによって、 DB のパスワードは `LB_DB_PASSWORD` という環境変数に格納されました

Repo を子プロセスとして起動し、 DB に接続します

:::note info
`types: ExtendedTypes` でベクトル型を拡張するように指定しています
:::

```elixir
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 を利用して拡張機能を追加します

```elixir
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　と重複しないように注意してください

```elixir
Ecto.Migrator.up(Repo, 21, Migrations.CreateVectorExtension)
```

### テーブルの作成

face テーブル作成の Migration を定義します

顔特徴量は 128 次元のベクトル型を使用します

```elixir
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 を適用します

```elixir
Ecto.Migrator.up(Repo, 31, Migrations.CreateFaceTable)
```

## DB への登録

DB 操作用のモジュールを定義します

```elixir
defmodule Face do
  use Ecto.Schema

  schema "face" do
    field(:file_path, :string)
    field(:embedding, Pgvector.Ecto.Vector)
  end
end
```

画像から取得した顔特徴量を全て追加します

```elixir
Repo.insert_all(Face, vectors)
```

実行結果
```elixir
{16, nil}
```

16 件全て登録できました

データを確認してみましょう

```elixir
Repo.all(Face)
```

実行結果
```elixir
[
  %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 を実行します

```elixir
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, [])
```

実行結果
```elixir
{: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` です

```SQL
...
    face AS src
  INNER JOIN
    face AS dst
  ON
    src.id < dst.id
 ...
 ```

### コサイン距離の計算

SQL では `A <=> B` でコサイン距離を求めています

コサイン類似度と逆で、小さいほど同一顔として判定するため、 0.5 未満のものを取得するようにしています

その他、 pgvector では以下の演算子を利用できます

| 演算子 |演算内容        |
|:-----:|:--------------|
|  `+`  |ベクトルの和      |
|  `-`  |ベクトルの差      |
|  `*`  |ベクトルの積      |
| `<->` |ユークリッド距離    |
| `<#>` |内積            |
| `<=>` |コサイン距離      |


## 結果の視覚化

同一人物と判定された画像同士を並べて、確かに同一人物か確認しましょう

```elixir
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2a17e006-3472-f09c-cacd-707206248863.png)

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

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

## まとめ

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

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