Elixir Image で画像の dHash を計算し、類似画像を見つける

## はじめに

例えば機械学習用の画像を収集するとき、違うファイル名で同じ画像、というのは邪魔になります

あるいはデータの整理をしているとき、各所にコピーされた同じ画像を集約して、不要なものを削除したい、ということもあります

完全に同じ画像だけでなく、サイズ違いや縦横の移動など、少しだけ加工したものも取り除きたくなります

そんなとき、画像ハッシュを用いることで、類似画像を検出することが可能です

Python の場合は ImageHash モジュールを使うことで画像ハッシュを計算できます

https://github.com/JohannesBuchner/imagehash

本記事では Elixir Image を使って、画像ハッシュの中でも高速な dHash を計算し、類似画像を見つけます

https://github.com/elixir-image/image

実行環境はもちろん Livebook です

https://livebook.dev/

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

https://github.com/RyoWakabayashi/elixir-learning/blob/main/livebooks/vix/dhash.livemd

## セットアップ

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

```elixir
Mix.install([
  {:image, "~> 0.38"},
  {:req, "~> 0.3"},
  {:kino, "~> 0.11"}
])
```

## 画像の取得

元画像として、 ElixirConf EU のロゴ画像を取得します

```elixir
original_img =
  "https://www.elixirconf.eu/assets/images/drops.svg"
  |> Req.get!()
  |> Map.get(:body)
  |> Image.from_binary!()
```

![スクリーンショット 2023-10-31 15.39.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/d8a08af6-a8fe-960d-97a7-20ef03fe3e61.png)

## 類似画像生成

比較対象としての類似画像を生成します

### グレースケール

白黒画像にします

```elixir
gray_img = Image.to_colorspace!(original_img, :bw)
```

![スクリーンショット 2023-10-31 15.40.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/a0d1ae5d-ae05-6e9f-f5cf-cd1140873793.png)

### リサイズ

半分のサイズに縮小します

```elixir
resized_img = Image.resize!(original_img, 0.5)
```

![スクリーンショット 2023-10-31 15.41.41.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/c0ddfa29-5686-ef23-89b1-c69420c93cf5.png)

### 回転

45度回転させます

```elixir
rotated_img = Image.rotate!(original_img, 45)
```

![スクリーンショット 2023-10-31 15.42.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/df80fe62-a27e-0aa3-da8f-9434fab97849.png)

### 切り取り

少しだけ外側を切り取ります

```elixir
cropped_img = Image.crop!(original_img, 0.07, 0.07, 0.9, 0.9)
```

![スクリーンショット 2023-10-31 15.43.47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/21febb45-0beb-c9e4-2573-8582bb20ba60.png)

### 文字追加

画像内に文字を追加します

```elixir
text_img = Image.Text.text!("Elixir", text_fill_color: :purple)

with_text_img = Image.compose!(original_img, text_img, x: 300, y: 100)
```

![スクリーンショット 2023-10-31 15.49.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/eca28868-c072-9f3a-9577-d2dc62049481.png)

### 別画像

Phoenix のロゴを別の画像として取得します

```elixir
other_img =
  "https://hexdocs.pm/phoenix/assets/logo.png"
  |> Req.get!()
  |> Map.get(:body)
  |> Image.from_binary!()
```

![スクリーンショット 2023-10-31 15.51.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/2d2e24d5-c183-2063-62cf-88d1367f2b34.png)

## 画像の一覧表示

元画像と他の画像を並べて表示します

```elixir
img_list =
  [
    original_img,
    gray_img,
    resized_img,
    rotated_img,
    cropped_img,
    with_text_img,
    other_img
  ]

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

![スクリーンショット 2023-10-31 15.54.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/f7eec6e9-e691-46f1-2d3e-99577350463c.png)

## ハッシュの計算

以下のようにして dHash を計算することができます

```elixir
{:ok, original_dhash} = Image.dhash(original_img)
```

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

```elixir
{:ok, <<56, 126, 207, 217, 253, 103, 103, 124>>}
```

サイズを指定しない場合、 dHash は 64 桁のビット配列 = 8 桁のバイト配列になります

値を整数にしてみます

```elixir
original_dhash_int = :binary.decode_unsigned(original_dhash, :big)
```

結果は `4070919648355772284` になります

16進数の文字列に変換してみます

```elixir
Integer.to_string(original_dhash_int, 16)
```

結果は `"387ECFD9FD67677C"` になります

2進数の文字列に変換してみます

```elixir
original_dhash_int
|> Integer.to_string(2)
|> String.pad_leading(64, "0")
```

結果は `"0011100001111110110011111101100111111101011001110110011101111100"` になります

別画像の dHash も取得し、2進数の文字列にします

```elixir
other_img
|> Image.dhash()
|> elem(1)
|> :binary.decode_unsigned(:big)
|> Integer.to_string(2)
|> String.pad_leading(64, "0")
```

結果は `"1111100001111111011111110111111100111111001111000000111000000000"` です

元画像と別画像の dHash を並べてみます

- 0011100001111110110011111101100111111101011001110110011101111100
- 1111100001111111011111110111111100111111001111000000111000000000

この dHash の値同士がどれくらい違っているか、は **ハミング距離** で計算します

ハミング距離は各ビットの排他的論理和（XOR）の合計、つまり各桁の 0 1 を比較したとき、値が違う桁の数です

上の例だと、 XOR は以下のようになります

|    |dHash                                                            |
|:---|:----------------------------------------------------------------|
|元画像|`0011100001111110110011111101100111111101011001110110011101111100`|
|別画像|`1111100001111111011111110111111100111111001111000000111000000000`|
|XOR  |`1100000000000001101100001010011011000010010110110110100101111100`|

XOR の 1 の個数は 27 なので、元画像 dHash と別画像 dHash のハミング距離は 27 になります

## ハミング距離の計算

上記のような計算をするのは大変なので、 Elixir Image では `Image.hamming_distance` に２つの画像を渡すだけで dHash のハミング距離を計算できるようにしてくれています

各画像についてハミング距離を計算し、画像とともに表示しましょう

```elixir
img_list
|> Enum.map(fn img ->
  hamming_distance =
    original_img
    |> Image.hamming_distance(img)
    |> elem(1)

  [hamming_distance, img]
  |> Kino.Layout.grid(columns: 1)
end)
|> Kino.Layout.grid(columns: 4)
```

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

![スクリーンショット 2023-10-31 16.35.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1485835/be0f170f-64a8-0711-2d81-ca48edb31881.png)

おおよその基準として、 10 以下ならほぼ同じ画像、 20 以下なら類似画像と判断できます

結果を確認してみましょう

|比較対象    |ハミング距離|判定結果|補足                       |
|:----------|--------:|:-----:|:-------------------------|
|元画像      |        0|同一画像|本当に全く同一の画像なので想定通り|
|グレースケール|        0|同一画像|白黒にしても同じ dHash になりました|
|リサイズ     |        0|同一画像|リサイズしても同じ dHash になりました|
|回転       |       29|別画像  |回転すると距離が大きくなりました    |
|切り抜き     |      16|類似画像|切り抜き具合によりますが、近くなっています|
|文字追加    |       1|ほぼ同一画像|小さい文字を加えたくらいだと同じ画像と判定できます|
|別画像      |      27|別画像   |別画像なので想定通りです      |

dHash の場合、実は計算の過程でグレースケール化、リサイズを実行しています

なので色の違いやサイズの違いは dHash では無いものと見做されます

逆に回転や切り抜きのような、概形が変わる場合は距離が大きくなります

aHash や pHash など、別の種類の画像ハッシュもあり、どの画像ハッシュを使うかによって「どういう画像を類似画像と見做すか」が違ってくるので注意しましょう

Elixir Image では現状 dHash のみサポートしています

## まとめ

Elixir Image を使って、 dHash による画像の類似度を計算できました

「類似」の基準によっては別の画像ハッシュを使わないといけないので、その点は注意しましょう