ベクトルデータベース Pinecone で顔をデータベース化し、似ている顔を探す

## はじめに

前回の記事で、 Livebook 上にノーコードで顔認証を実装しました

https://qiita.com/RyoWakabayashi/items/fbafaa31a882b1b15fbe

ただし、この方法だと毎回顔写真を選択し、顔の特徴量を計算することになります

比較対象となる顔の特徴量をデータベースに入れておけば、そんな必要はありません

というわけで、ベクトル（テンソル）に特化したベクトルデータベース Pinecone を使って、顔特徴量の保存、検索を実行します

https://www.pinecone.io/

公式サイトに "Long-Term Memory for AI" とあるように、 AI の入出力として使われるベクトルを保存、検索するためのデータベースです

今回も Livebook で実装します

https://livebook.dev/

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

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

## セットアップ

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

```elixir
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](https://github.com/cocoa-xu/evision) は最新版が未リリースなので、 GitHub からコードを取得し、ビルドします

[pinecone](https://github.com/withbroadcast/pinecone) は Pinecone 用の Elixir モジュールですが、まだ荒削りな状態で、必要なものが揃ってはいませんが、とりあえずこれを使っていきます

[jason](https://github.com/michalmuskala/jason) は Pinecone API　へのリクエストを JSON 形式に変換するために使います

## 画像の準備

今回の例では "/home/livebook/evision/test-images/" 配下に .jpg と .png の画像ファイルを用意しました

```elixir
image_files = Path.wildcard("/home/livebook/evision/test-images/*.{jpg,png}")
```

とりあえず顔写真を表示してみます

```elixir
images =
  image_files
  |> Enum.map(fn image_file ->
    Evision.imread(image_file)
  end)

Kino.Layout.grid(images, columns: 4)
```

![スクリーンショット 2023-07-11 9.36.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/505d1125-96bc-6ecb-25aa-5d33d2e52cbe.png)

左上から7枚は AI生成画像です

「メガネをかけた日本人エンジニア」など、私っぽくなるように生成したものから、「日本人議員」で生成した女性の顔まで、色々作ってみました

8枚目は生身の人間で、実在する私の後輩です（記事への掲載について本人了承済）

9枚目以降の8枚はすべて私です

色々な角度だったり、メガネを外していたり、マスクをしていたり、プリクラで美顔加工されていたり、AIにイラスト化されていたりします

今回使用する [SFace](https://github.com/zhongyy/SFace) では、顔の特徴量を長さ 128 のベクトルで表します

SFace は特徴量ベクトル間の[コサイン類似度](https://ja.wikipedia.org/wiki/%E3%83%99%E3%82%AF%E3%83%88%E3%83%AB%E3%81%AE%E3%81%AA%E3%81%99%E8%A7%92)が、同一人物なら 1 に近く、別人なら -1 に近くなるよう学習しています

うまく個人識別できていれば、下段２つの8枚（同一人物）は特徴量が近く = ベクトル間のコサイン類似度が 1 に近くなるはずです

## 顔特徴量の抽出

顔特徴量の抽出ように [SFace](https://github.com/zhongyy/SFace) 、顔検出用に [YuNet](https://github.com/ShiqiYu/libfacedetection) のモデルを読み込みます

```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.9,
    top_k: 5
  )
```

各画像について、以下の処理を実行します

- A. 顔を検出する
- B. A の結果から顔の座標情報を取得する
- C. 顔画像と B を使い、顔特徴量を取得する
- D. 顔検出結果を視覚化する
- C と D を配列に格納する

D（顔検出結果の視覚化）を表示します

```elixir
[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-07-11 9.58.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/358d13c9-10ef-1528-b7f3-378d43b22256.png)

四角形が顔の検出範囲、四角形の中の点は両目、鼻、口角の検出位置です

特徴量の中身を見てみましょう

```elixir
feature_list
```

実行結果は以下のようになります

```elixir
[
  %Evision.Mat{
    channels: 1,
    dims: 2,
    type: {:f, 32},
    raw_type: 5,
    shape: {1, 128},
    ref: #Reference<0.2446145276.1381105673.600>
  },
  ...
]
```

形が 1 * 128 で、 32 ビット浮動小数の行列になっています

先頭の行列をテンソルとして表示してみます

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

結果は以下のようになります

```elixir
#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段目の左端と、最下段の右端（どちらとも私）を比較してみます

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

実行結果は以下のようになります

```elixir
%{matched: true, measure: "cosine_score", retval: 0.8260765364864255}
```

コサイン類似度は 0.826 で非常に高く、 `matched: true` 同一人物、という判定結果を出し増田

evision ではコサイン類似度の閾値 0.363 で同一人物かどうかを判定しています

下から2段目の左端と、最上段の左端（別人）を比較してみます

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

実行結果は以下のようになります

```elixir
%{matched: false, measure: "cosine_score", retval: -0.1544433785079491}
```

コサイン類似度は -0.154 で負にになっており、 `matched: false` で別人判定です

顔特徴量は正しく取得できたようです

顔特徴量を Pinecone に登録できるベクトルの形式に変換します

```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()

    %{
      values: values,
      id: image_file
    }
  end)
```

実行結果は以下のようになります

```elixir
[
  %{
    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" からサインアップしてください

![スクリーンショット 2023-07-11 10.25.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c75cd513-a5a5-2b49-f124-92b6d00cf583.png)

メールアドレス等でサインアップすると、初期化中の画面が表示されるので、しばらく待ちます

![スクリーンショット 2023-07-11 10.33.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/0632c9cf-d685-68c2-458e-193fe1cc65db.png)

以下のような表示になれば使い始めることができます

![スクリーンショット 2023-07-11 10.35.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7874032e-33bd-355e-4577-7c7684333745.png)

左メニュー "API Keys" を開いておきます

![スクリーンショット 2023-07-11 10.37.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/5d0b0202-8bea-811b-db41-e761fc2d53be.png)

このままブラウザコンソールから操作してもいいのですが、せっかくなので Elixir で操作していきます

認証情報を入力するためのテキストインプットを作ります

Pinecone コンソールの "API Keys" から、 Value の値を api_key_input に、 Environment の値を environment_input に入力します

```elixir
api_key_input = Kino.Input.password("API_KEY")
```

```elixir
environment_input = Kino.Input.text("ENVIRONMENT")
```

![スクリーンショット 2023-07-11 10.40.39.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/7e48b875-38d0-8d86-90f0-8881dab1ca16.png)

入力した値を変数に読み込みます

```elixir
api_key = Kino.Input.read(api_key_input)
environment = Kino.Input.read(environment_input)
```

インデックス操作の機能が Elixir の pinecone モジュール内に存在しないため、自作します

```elixir
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"` を入れています

```elixir
controller =
  Pinecone.Controller.new(
    environment: environment,
    api_key: api_key
  )

"dummy"
```

今回は顔検索用インデックスなので、インデックス名を "face-search" にします

```elixir
index_name = "face-search"
```

以下のようにパラメータを指定し、インデックスを作成します

- name: インデックス名
- dimension: ベクトルのサイズ（今回の顔特徴量は128）
- metric: 距離・類似度の計算方法（今回はコサイン類似度）
- pod_type: Pinecone でデータを格納するハードウェアのタイプを指定します（今回は無料プランで利用可能な s1 = ストレージ効率重視を使います）

```elixir
Pinecone.Controller.create_index(controller,
  name: index_name,
  dimension: 128,
  metric: "cosine",
  pod_type: "s1"
)
```

実行結果は以下のようになります

```elixir
{:ok, ""}
```

ただし、この状態はインデックスの作成を開始しただけでまだ使えません

インデックスの一覧を取得してみます

```elixir
controller
|> Pinecone.Controller.list_indexes()
|> elem(1)
```

実行結果は以下のようになります

```elixir
["face-search"]
```

インデックスの詳細を取得します

```elixir
controller
|> Pinecone.Controller.describe_index(index_name)
|> elem(1)
```

実行結果は以下のようになります

```elixir
%{
  "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を取得しておきます

```elixir
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"` を出しておきます

```elixir
client =
  Pinecone.Client.new(
    environment: environment,
    api_key: api_key,
    project: project,
    index: index_name
  )

"dummy"
```

準備しておいた `vectors` を登録します

Pinecone では　upsert なので、同じ ID のベクトルを登録した場合、必ず上書きされる点に注意してください

```elixir
Pinecone.Vector.upsert(client, %{vectors: vectors})
```

実行結果は以下のようになります

```elixir
{:ok, %{"upsertedCount" => 16}}
```

16個の顔特徴量が登録できました

## ベクトルの検索

私の顔を検索してみます

`vectors` から インデックス 8 の顔特徴量を取得します

```elixir
vector =
  vectors
  |> Enum.at(8)
  |> Map.get(:values)
```

この顔特徴量に近い上位10個の顔特徴量を取得します

- topK: 上位何個まで取得するか
- vector: 検索するベクトル（今回は顔特徴量）

```elixir
matches =
  client
  |> Pinecone.Vector.query(%{topK: 10, vector: vector})
  |> elem(1)
  |> Map.get("matches")
```

実行結果は以下のようになります

```elixir
[
  %{"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` を画像ファイルのパスにしているので、そのまま読み込んで表示してみます

```elixir
matches
|> Enum.map(fn match ->
  [
    Kino.Markdown.new("#{match["score"]}"),
    Evision.imread(match["id"])
  ]
  |> Kino.Layout.grid()
end)
|> Kino.Layout.grid(columns: 4)
```

![スクリーンショット 2023-07-11 11.10.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a1ab2486-ccfb-8752-fae6-89855053817f.png)

各画像の上にスコアも表示しています

左上、トップの画像は検索した顔と同じ顔です

全く同じ顔なので、スコアは 1 になっています

2番目はスコア 0.826 で、確かに同一人物です（太って髪が伸びて変な表情になっていますが）

3番目はメガネを外した同一人物、4番目、5番目も私です

6番目についに別人（AIが生成した顔画像）が出てきました

スコアは 0.310　なので、閾値が 0.363 なら別人判定ですね

7番目、8番目、10番目は加工されたりマスクをしたりした私ですが、そもそも顔検出位置がずれていたこともあってこれは仕方ないですね

9番目は別人（実在する私の後輩）で、コサイン類似度は 0.178 でした

判定結果の正しさは SFace　や YuNet の問題なので置いておくとして、確かに Pinecone でベクトル検索が実行できました

## インデックスの削除

最後にインデックスを削除しておきます

```elixir
Pinecone.Controller.delete_index(controller, index_name)
```

## まとめ

Pinecone を使うことで、ベクトルの保存、検索ができることを確認しました

今回実装したように顔認証用のデータベースとして使うことができます

また、テキストをAIでベクトル化することで、非常に柔軟な文書検索を構築することも可能です

公式の Python 用チュートリアルから簡単に体験できます

https://docs.pinecone.io/docs/ecommerce-search

もちろん、今回やったように Elixir でも簡単に使えたので、 Elixir でベクトルを扱うときには使ってみましょう