PHP Advent Calendar 2019、20日目のエントリです。
はじめに
画像関係の案件に携わることになって結果的にPHPだけで画像検証しようってことになった話です。
Pythonで解析計画もあったけどPythonエンジニアが居なかったのと動的検証ではないのでOpenCVとかゴリゴリ解析を使うことは多分無いという前提で。
TL;DR
デモ機作りました。適当な画像を入れて動かしてみてください。
demo-app
急いで作ったやつなので中身がとても汚いです許して
画像
今年めちゃくちゃ流行りましたね(見るの忘れた勢)
オリジナル
今回はこの画像をベースに複数の画像を合致してあっているかどうかを検証します。
検証に使うパッケージ
ImageHash
ImageHash(Python版)についてはごちうサーチが非常に素晴らしいご説明をしているので大部分は割愛します。
Image Hashは画像情報をピクセル化してグレースケールに変換後、グレースケールの輝度差をパラメータに置き換えます。
Image HashにはそれぞれAverage・Block・Difference・Preceptualの出力方法があります。
それぞれの出力とその意味についても簡単な説明をします。
Average
このハッシュ値には以下の処理順があります。
1. 画像をグレースケール化
2. 画像を縮小(8:9)
3. 画素の平均値を計算する
4. 各画素を平均値から走査して距離を抽出する
4番目の距離に関しては基本的に0|1で判定しているので処理速度はめちゃくちゃ早いですが精度に関しては荒いです。
jpegデータをpngに変えたりすると画像にノイズが入るので、その時のズレが大きく影響することになります。
Block
blockhash.ioに基づいたハッシュアルゴリズムです。
PythonやJavascriptにはすでに採用されていますが、PHPではまだ実用前状態とのこと。
じつは検証中にUndefined offset: 16
が発生しまして、まだ実用ベースに移す前の状態っぽいです。
が、これが使えるようになると、たとえ画像サイズが小さくなった同一のファイルを読み込ませようとするとちゃんと一致させるように適切に調整するようです。使えればの話ですが。
Difference
たぶんこれが1番使われていると思います。
このハッシュ値はAvarage Hashと同じような処理をします。
ただし、算出方法は輝度を各ピクセルで平均値を集めて走査。64bitで結果を表示します。
こちらの場合、いかにノイズが入っていようともピクセルの輝度で判定するので粗さはノイズとは大きく関連付けにくいです。
これはどの一致方法も同じですが、画像サイズを縮小するだけなら良いですが、拡大する場合は一致しないと思います。
(8*9で逆に拡大するってどういうサイズやねんって突っ込みはさておいて)
Preceptual
ImageHashオリジナルの画像一致方法です。まだ開発中とのこと。
ソースを見る限りsinとか突然の数学要素(画像アルゴリズムって言ってんだよなぁ……)が入っているのできっとそういうやつ。
通し検証
PHPでベタ書きしようかなとも思ったのですがserverを立てるのを面倒くさがったのでLaravelでphp artisan serv
しました。
導入については他のQiitaエントリを参考にしてください。(媚売り)
ImageHashを導入するにはPackagistにあるのでこちらからcomposer require
してください。
動かそう
操作出来るようのデモ機を用意しました。
これは簡単に画像データを乗せるだけで結果が分かるようになっています。
demo-app
public function store(Request $request)
{
$master = $request->cinderella;
$target = $request->target;
$param['average'] = $this->average->getHash($master, $target);
$param['difference'] = $this->difference->getHash($master, $target);
$param['perceptual'] = $this->perceptual->getHash($master, $target);
dd($param);
}
わざとこんな感じの分け方にしていますが、基本的には一緒の流れを辿るようにしています。
(実装の流れをコピペして全部Baseクラスで継承できるのにしなかった人)
public function getHash(UploadedFile $master, UploadedFile $target) : array
{
$harsher = new ImageHash(new PerceptualHash());
$masterHash = $harsher->hash($master->getPathname());
$targetHash = $harsher->hash($target->getPathname());
$distance = $masterHash->distance($targetHash);
//0であればあるほど近似画像になるので閾値の上限設定を行う
if ($distance < 15) {
$this->distance = 'Good to range distance: ' . $distance;
} else {
$this->distance = 'No matching image range distance: ' . $distance;
}
self::toHex($masterHash->toHex(), $targetHash->toHex());
self::toInt($masterHash->toInt(), $targetHash->toInt());
self::toBits($masterHash->toBits(), $targetHash->toBits());
return $this->resultParam = [
'distanceResult' => $this->distance,
'hexResult' => $this->hexParametor . "master: $this->hexMaster, target: $this->hexTarget",
'intResult' => $this->hexParametor . "master: $this->intMaster, target: $this->intTarget",
'bitsResult' => $this->hexParametor . "master: $this->bitsMaster, target: $this->bitsMaster"
];
}
private function toHex($master, $target): void
{
if ($master === $target) {
$this->hexParametor = 'maybe matching string parameter: ';
}
$this->hexMaster = $master;
$this->hexTarget = $target;
}
他のBlock
とかAverage
とかDifference
とか、ぜんぶ同じような動きです。
self::toInt
とself::toBits
は下のprivate function toHex
の変数違いの同じような取得方法で扱っています。
この状態で実行をすると以下の結果をdd
関数から受け取ることができます。
array:3 [▼
"average" => array:4 [▼
"distanceResult" => "Good to range distance: 1"
"hexResult" => "master: f0e0f8f8e8e00e1c, target: f0e0f8f8e8e04e1c"
"intResult" => "master: 1.7357146711828E+19, target: 1.7357146711828E+19"
"bitsResult" => "master: 1111000011100000111110001111100011101000111000000000111000011100, target: 1111000011100000111110001111100011101000111000000000111000011100"
]
"difference" => array:4 [▼
"distanceResult" => "Good to range distance: 0"
"hexResult" => "maybe matching string parameter: master: 5bb53dc5f6fb632f, target: 5bb53dc5f6fb632f"
"intResult" => "maybe matching string parameter: master: 6608255948697592623, target: 6608255948697592623"
"bitsResult" => "maybe matching string parameter: master: 101101110110101001111011100010111110110111110110110001100101111, target: 1011011101101010011110111000101111101101111101 ▶"
]
"perceptual" => array:4 [▼
"distanceResult" => "Good to range distance: 1"
"hexResult" => "master: ecd252ac487651c0, target: ecd352ac487651c0"
"intResult" => "master: 1.7064792837964E+19, target: 1.7065074312941E+19"
"bitsResult" => "master: 1110110011010010010100101010110001001000011101100101000111000000, target: 1110110011010010010100101010110001001000011101100101000111000000"
]
]
ちなみに以下は完全に同じ画像(オリジナル)を使った場合です
array:3 [▼
"average" => array:4 [▼
"distanceResult" => "Good to range distance: 0"
"hexResult" => "maybe matching string parameter: master: f0e0f8f8e8e00e1c, target: f0e0f8f8e8e00e1c"
"intResult" => "maybe matching string parameter: master: 1.7357146711828E+19, target: 1.7357146711828E+19"
"bitsResult" => "maybe matching string parameter: master: 1111000011100000111110001111100011101000111000000000111000011100, target: 111100001110000011111000111110001110100011100 ▶"
]
"difference" => array:4 [▼
"distanceResult" => "Good to range distance: 0"
"hexResult" => "maybe matching string parameter: master: 5bb53dc5f6fb632f, target: 5bb53dc5f6fb632f"
"intResult" => "maybe matching string parameter: master: 6608255948697592623, target: 6608255948697592623"
"bitsResult" => "maybe matching string parameter: master: 101101110110101001111011100010111110110111110110110001100101111, target: 1011011101101010011110111000101111101101111101 ▶"
]
"perceptual" => array:4 [▼
"distanceResult" => "Good to range distance: 0"
"hexResult" => "maybe matching string parameter: master: ecd252ac487651c0, target: ecd252ac487651c0"
"intResult" => "maybe matching string parameter: master: 1.7064792837964E+19, target: 1.7064792837964E+19"
"bitsResult" => "maybe matching string parameter: master: 1110110011010010010100101010110001001000011101100101000111000000, target: 111011001101001001010010101011000100100001110 ▶"
]
]
Image Hash - Differenceロジック
中身気になりますよね。僕も気になった。
なので中身を追うことにしましたが結構面白い組み方をしていました。
全例を出すと長くなりすぎるので今回はよく使われるDifference(dhash)
の中身を漁ります。
Image Hashライブラリには基底にIntervention ImageというGD/imagemagickを使用したライブラリがあり、基本的にはIntervention Imageのドライバを使って必要に応じた画像の数値化を行なうものとなっています。
例えば実際のファイルを読んでいくと
ハッシュ化する
// For this implementation we create a 8x9 image.
$width = $this->size + 1;
$height = $this->size;
// Resize the image.
$resized = $image->resize($width, $height);
Interventionを使っているのは以下のresizeになります。
$image->resize($width, $height);
これを取得することでどのような結果を取得するかというと
9
8
Intervention\Image\Image {#259 ▼
#driver: Intervention\Image\Gd\Driver {#266 ▼
+decoder: Intervention\Image\Gd\Decoder {#268 ▼
-data: null
}
+encoder: Intervention\Image\Gd\Encoder {#269 ▼
+result: null
+image: null
+format: null
+quality: null
}
}
#core: gd resource @437 ▼
size: "9x8"
trueColor: true
}
#backups: []
+encoded: ""
+mime: "image/png"
+dirname: "C:\xampp\tmp"
+basename: "phpE6BD.tmp"
+extension: "tmp"
+filename: "phpE6BD"
}
このようにDGライブラリにデータを噛ませてInterventionの実行が出来るようになります。
以下では$resized->pickColor(0, $y);
と$resized->pickColor(0, $y);
でピクセルの高さと幅を取得するようにしています。
$bits = [];
for ($y = 0; $y < $height; $y++) {
// Get the pixel value for the leftmost pixel.
$rgb = $resized->pickColor(0, $y);
$left = (int) floor(($rgb[0] * 0.299) + ($rgb[1] * 0.587) + ($rgb[2] * 0.114));
for ($x = 1; $x < $width; $x++) {
// Get the pixel value for each pixel starting from position 1.
$rgb = $resized->pickColor($x, $y);
$right = (int) floor(($rgb[0] * 0.299) + ($rgb[1] * 0.587) + ($rgb[2] * 0.114));
// Each hash bit is set based on whether the left pixel is brighter than the right pixel.
// http://www.hackerfactor.com/blog/index.php?/archives/529-Kind-of-Like-That.html
$bits[] = (int) ($left > $right);
// Prepare the next loop.
$left = $right;
}
}
実際に計算した結果が以下のような形となる。
[2019-12-20 01:42:27] local.INFO: Height left
[2019-12-20 01:42:27] local.DEBUG: 202
[2019-12-20 01:42:27] local.INFO: Width RGB
[2019-12-20 01:42:27] local.DEBUG: array (
0 => 209,
1 => 213,
2 => 204,
3 => 1.0,
)
[2019-12-20 01:42:27] local.INFO: Width right
[2019-12-20 01:42:27] local.DEBUG: 210
[2019-12-20 01:42:27] local.INFO: Width RGB
[2019-12-20 01:42:27] local.DEBUG: array (
0 => 207,
1 => 207,
2 => 203,
3 => 1.0,
)
[2019-12-20 01:42:27] local.INFO: Width right
[2019-12-20 01:42:27] local.DEBUG: 206
[2019-12-20 01:42:27] local.INFO: Width RGB
[2019-12-20 01:42:27] local.DEBUG: array (
0 => 224,
1 => 222,
2 => 214,
3 => 1.0,
)
[2019-12-20 01:42:27] local.INFO: Width right
[2019-12-20 01:42:27] local.DEBUG: 221
[2019-12-20 01:42:27] local.INFO: Width RGB
[2019-12-20 01:42:27] local.DEBUG: array (
0 => 153,
1 => 153,
2 => 146,
3 => 1.0,
)
算出したパラメータは最終的にこのような形となる
array:64 [▼
0 => 0
1 => 1
2 => 0
3 => 1
4 => 1
5 => 0
6 => 1
7 => 1
8 => 1
9 => 0
10 => 1
11 => 1
12 => 0
13 => 1
14 => 0
15 => 1
16 => 0
17 => 0
18 => 1
19 => 1
20 => 1
21 => 1
22 => 0
23 => 1
24 => 1
25 => 1
26 => 0
27 => 0
28 => 0
29 => 1
30 => 0
31 => 1
32 => 1
33 => 1
34 => 1
35 => 1
36 => 0
37 => 1
38 => 1
39 => 0
40 => 1
41 => 1
42 => 1
43 => 1
44 => 1
45 => 0
46 => 1
47 => 1
48 => 0
49 => 1
50 => 1
51 => 0
52 => 0
53 => 0
54 => 1
55 => 1
56 => 0
57 => 0
58 => 1
59 => 0
60 => 1
61 => 1
62 => 1
63 => 1
]
Jenssegers\ImageHash\Hash {#262 ▼
#value: phpseclib\Math\BigInteger {#230 ▼
+value: "6608255948697592623"
+is_negative: false
+precision: -1
+bitmask: false
+hex: null
value: "0x5bb53dc5f6fb632f"
engine: "bcmath (OpenSSL)"
}
}
ここまでの流れを以下の3行で行っています。
$harsher = new ImageHash(new DifferenceHash());
$masterHash = $harsher->hash($master->getPathname());
$targetHash = $harsher->hash($target->getPathname());
一致度を検証
先程まではハッシュ化して検証する前座みたいなものですが、次は一致しているかどうかの数値化が行われます。
public function distance(Hash $hash)
{
if (extension_loaded('gmp')) {
return gmp_hamdist('0x' . $this->toHex(), '0x' . $hash->toHex());
}
$bits1 = $this->toBits();
$bits2 = $hash->toBits();
$length = max(strlen($bits1), strlen($bits2));
// Add leading zeros so the bit strings are the same length.
$bits1 = str_pad($bits1, $length, '0', STR_PAD_LEFT);
$bits2 = str_pad($bits2, $length, '0', STR_PAD_LEFT);
return count(array_diff_assoc(str_split($bits1), str_split($bits2)));
}
受け取ったデータをBits化する
(今回使用した画像に欠損はないのでstr_pad
で文字長を埋められることはないです)
"1111000011100000111110001111100011101000111000000000111000011100"
"1111000011100000111110001111110011111100111000000000111000001100"
64
返り値では3つの処理が入っています。
1. str_split()
で配列に戻す
2. array_diff_assoc()
でそれぞれの配列の中身がいくつズレているかを添字から取得。
3. count()
でズレた数を計算します
array:4 [▼
29 => "0"
35 => "0"
37 => "0"
59 => "1"
]
今回は4つ一致していないことが判明しました。
この部分までの挙動は
$distance = $masterHash->distance($targetHash);
この1行に集約されています。
が、結果。
8
※原因調べてみたんですが直接的なものが見つからず……。
一応悪影響はないはず(と思いたい)
しきい値を決める
これでだいたい一致しているかどうかの距離が分かったと思うので、最後はしきい値を設定して合っているかどうかのチェックをすれば良い感じです。
0に近ければ近いほど一致しているという点から、
たぶん一致している:15
だいたい一致:10
ほぼ一致:5
同じ画像使ってる?:0
みたいな感じです。
お疲れさまでした。
感想
最終的にImageHashすごいねっていう感じになっちゃいました。(変な挙動は見つけちゃったりしたけど)
実際にはもっとLibpuzzleとか使ったりして組み合せたりしないと高い精度は保てないかも。
そもそも高精度を求めるならOpenCVを使ったほうが……
本音
本当はLibpuzzleもやりたかったんですがDockerの環境構築が間に合わずまた次回になりましたことお詫び申し上げ。
特徴点一致的な部分でCrop作業もしたかったです。GDライブラリは色々と役立つことが多いのでGDライブラリ良いぞ布教エントリを今度は書きます。