こちらのツールを開発する上で必須になるのが画像処理。
その中でも、「相手のポケモンのアイコンを取得して、そのポケモン名を返す」という処理の実装に丸1日使ってしまったので、備忘録も含めて書き残して置こうと思います。
目的
今回作成するプログラムはこんな感じ。
- 2つの画像ファイルを比較して、その画像がどれぐらい類似しているのかを計算する
- 上記処理について、処理時間10ms以内
作ろうとしているツールがリアルタイム画像解析を必要とするので、処理に時間がかかるとどうしてもリアルタイムにできない部分があります。
ので、高速で処理できて、かつ類似画像の検出率を99%以上を目指します。
アルゴリズム
今回はPerceptual Hashを利用した類似度の計算を行います。
Perceptual Hashにも何種類かあるようなのですが、dHashと言う隣接するピクセルの輝度値?の差分を比較することによるハッシュ計算方法を使います。
詳細はこちらのサイトが参考になります。と言うかなりました。
dHashを用いた類似度計算フロー
dHashを用いた画像の類似度の計算方法の概要は
- 画像をグレースケールに変換する
- グレースケール化した画像を9×8ピクセルの画像に変換する
- 変換した画像のdHash値を求める(だいたいは64bitでやるみたいです)
- 求めたdHash値の差分をチェックし、差分数を求める
- 求めた差分数が少なければ少ないほど類似している画像
って感じです。
つまり、C#でdHashを用いた類似度計算をしたければ、
上記の1~4を関数化してやればよい!
ってだけです。
早速作っていきましょう。
プログラミング
開発環境
Visual Studio 2019
画像をグレースケールに変換する
まずは画像をグレースケール化します。
dHashは隣接している画像の輝度差を計算するので、グレースケールしておくと計算が非常に楽になるためです。
と言うわけでグレースケール化する関数はこちら。
using System.Drawing;
/// <summary>
/// 画像をグレースケール化
/// </summary>
/// <param name="_img">グレースケール化する画像ファイル</param>
public Bitmap convertImageGray(Bitmap _img)
{
int imgWidth = _img.Width;
int imgHeight = _img.Height;
byte[,] data = new byte[imgWidth, imgHeight];
// bitmapクラスの画像ピクセル値を配列に挿入
for (int i = 0; i < imgHeight; i++)
{
for (int j = 0; j < imgWidth; j++)
{
// グレースケールに変換
data[j, i] = (byte)((_img.GetPixel(j, i).R + _img.GetPixel(j, i).B + _img.GetPixel(j, i).G) / 3);
}
}
// 画像データの幅と高さを取得
int w = data.GetLength(0);
int h = data.GetLength(1);
Bitmap resultBmp = new Bitmap(w, h);
for (int i = 0; i < h; i++)
{
for (int j = 0; j < w; j++)
{
resultBmp.SetPixel(j, i, Color.FromArgb(data[j, i], data[j, i], data[j, i]));
}
}
return resultBmp;
}
こちらのサイトを参考(というかほぼそのままですが)に作成。
byte型の二次元配列変数にグレースケール化した画像のRGB値を入れて、あとで再描写する感じです。
最初読んだときは全くよく分かりませんでしたけど・・・
また、Bitmapクラスを使用するのでSystem.Drawingは必要になります。
このままコピペして貼り付ければ使えるはず・・・
画像を9×8ピクセルの画像に変換する
64bitのハッシュ値を計算するにあたって一番楽な方法は画像自体を8×9にしてしまうこと。
そうすることで隣接するピクセルの計算箇所は8×8になり、ちょうど64bitとなります。
と言うわけで画像をリサイズする関数を作成します。
後々のことを考えて、9×8ではなく、引数で指定した幅と高さにリサイズする関数にしておきましょう。
using System.Drawing;
/// <summary>
/// 画像をリサイズ
/// </summary>
/// <param name="_img">リサイズする画像ファイル</param>
/// <param name="_width">リサイズ後の画像の幅</param>
/// <param name="_height">リサイズ後の画像の高さ</param>
public Bitmap resizeImage(Bitmap _img, int _width, int _height)
{
Bitmap resizeBmp = new Bitmap(_width, _height);
Graphics g = Graphics.FromImage(resizeBmp);
// 拡大するときのアルゴリズムの指定
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
g.DrawImage(_img, 0, 0, _width, _height); // リサイズ
g.Dispose();
return resizeBmp;
}
GraphicsクラスのDrawImage関数を使えば簡単にリサイズできるみたいです。
特に難しい処理もしていない(と言うか難しい処理はGraphicsクラスがしてくれている)ので語ることもありません。
こちらもそのままコピペしたら使えるかと。
画像のdHash値を求める
dHashは隣接するピクセルとの輝度差を計算することで算出できます。
こちらのサイトにて図を使ってかなり丁寧でわかりやすく解説されている方がいるので、算出アルゴリズムはそちらで勉強してください。
ここでは求めるための関数のみ紹介します。
using System.Drawing;
/// <summary>
/// 画像のdHashを計算する
/// </summary>
/// <param name="_img">PerceptualHashを計算する画像ファイル</param>
public string calcPerceptualDhash(Bitmap _img)
{
Bitmap grayBmp = convertImageGray(_img); //画像をグレースケール化
Bitmap resizeBmp = resizeImage(grayBmp, 9, 8); //画像をリサイズ
string hash = "";
for (int y = 0; y < resizeBmp.Height; y++)
{
for (int x = 0; x < resizeBmp.Width - 1; x++)
{
if (resizeBmp.GetPixel(x, y).R > resizeBmp.GetPixel(x + 1, y).R)
{
hash = hash + "1";
}
else
{
hash = hash + "0";
}
}
}
return hash; //64bitの2進数文字列を返す(ex:"00111101101010111010101....")
}
画像をグレースケール化する部分、そして9×8にリサイズする処理に関しては、先程作成した関数を利用します。
そして、画像のピクセルをいじるときに使われる上等手段であるxyネストループ処理ですが、計算する幅は8×8なので、xの方のループ終了条件は-1しています。
んで「if (resizeBmp.GetPixel(x, y).R > resizeBmp.GetPixel(x + 1, y).R)」で
隣接(今回の場合は右隣り)のピクセルの輝度を計算しています。
今回はグレースケール化しているのでRedのみで判断してよいでしょう・・・多分・・・
本当ならRGB足して比較しないといけないんでしょうけどね・・・。
戻り値は2進数の文字列。
もし16進数化したかったりする場合は「Convert.ToInt64」や「Convert.ToString」関数を使用しましょう。
dHash値を比較して差分を出力する
正直ここまできたら後は消化試合です。
求めたdHashを使って差分数(≒類似度)を計算します。
ちなみに、この差分数のことを「ハミング距離」を言うそうです。へぇ~
/// <summary>
/// 2つのハッシュ値のハミング距離を求める
/// </summary>
/// <param name="_hashA">比較元のハッシュ値(64bit)</param>
/// <param name="_hashB">比較先のハッシュ値(64bit)</param>
public int calcHammingDistance(string _hashA, string _hashB)
{
int hammingCount = 0; //ハミング距離計算用の変数
for (int i = 0; i < hashA.Length; i++)
{
if (hashA[i] != hashB[i])
{
// もしビット差分があればカウントを増やす
hammingCount++;
}
}
return hammingCount;
}
・・・特に解説することもないほどの単純な関数ですね。
文字列を1文字ずつ頭から比較していって、差分があればカウンターを増やしてるだけです。
完成したクラス CompareImage
と言うわけで、今までの関数を全てまとめてちょっと整理したクラスがこちら。
その名もCompareImageクラス!だっせ
using System.Drawing;
class CompareImage
{
/// <summary>
/// 画像をグレースケール化
/// </summary>
/// <param name="_img">グレースケール化する画像ファイル</param>
public Bitmap convertImageGray(Bitmap _img)
{
int imgWidth = _img.Width;
int imgHeight = _img.Height;
byte[,] data = new byte[imgWidth, imgHeight];
// bitmapクラスの画像ピクセル値を配列に挿入
for (int i = 0; i < imgHeight; i++)
{
for (int j = 0; j < imgWidth; j++)
{
// グレースケールに変換
data[j, i] = (byte)((_img.GetPixel(j, i).R + _img.GetPixel(j, i).B + _img.GetPixel(j, i).G) / 3);
}
}
// 画像データの幅と高さを取得
int w = data.GetLength(0);
int h = data.GetLength(1);
Bitmap resultBmp = new Bitmap(w, h);
for (int i = 0; i < h; i++)
{
for (int j = 0; j < w; j++)
{
resultBmp.SetPixel(j, i, Color.FromArgb(data[j, i], data[j, i], data[j, i]));
}
}
return resultBmp;
}
/// <summary>
/// 画像をリサイズ
/// </summary>
/// <param name="_img">リサイズする画像ファイル</param>
/// <param name="_width">リサイズ後の画像の幅</param>
/// <param name="_height">リサイズ後の画像の高さ</param>
public Bitmap resizeImage(Bitmap _img, int _width, int _height)
{
Bitmap resizeBmp = new Bitmap(_width, _height);
Graphics g = Graphics.FromImage(resizeBmp);
// 拡大するときのアルゴリズムの指定
g.InterpolationMode = System.Drawing.Drawing2D.InterpolationMode.HighQualityBicubic;
g.DrawImage(_img, 0, 0, _width, _height); // リサイズ
g.Dispose();
return resizeBmp;
}
/// <summary>
/// 画像のdHashを計算する
/// </summary>
/// <param name="_img">PerceptualHashを計算する画像ファイル</param>
public string calcPerceptualDhash(Bitmap _img)
{
Bitmap grayBmp = convertImageGray(_img); //画像をグレースケール化
Bitmap resizeBmp = resizeImage(grayBmp, 9, 8); //画像をリサイズ
string hash = "";
for (int y = 0; y < resizeBmp.Height; y++)
{
for (int x = 0; x < resizeBmp.Width - 1; x++)
{
if (resizeBmp.GetPixel(x, y).R > resizeBmp.GetPixel(x + 1, y).R)
{
hash = hash + "1";
}
else
{
hash = hash + "0";
}
}
}
return hash; //64bitの2進数文字列を返す(ex:"00111101101010111010101....")
}
/// <summary>
/// 2つのハッシュ値のハミング距離を求める
/// </summary>
/// <param name="_hashA">比較元のハッシュ値(64bit)</param>
/// <param name="_hashB">比較先のハッシュ値(64bit)</param>
public int calcHammingDistance(string _hashA, string _hashB)
{
int hammingCount = 0; //ハミング距離計算用の変数
for (int i = 0; i < hashA.Length; i++)
{
if (hashA[i] != hashB[i])
{
// もしビット差分があればカウントを増やす
hammingCount++;
}
}
return hammingCount;
}
/// <summary>
/// 2つの画像の差分(ハミング距離)を求める
/// </summary>
/// <param name="_imgA">比較する画像A(64bit)</param>
/// <param name="_imgB">比較する画像B(64bit)</param>
public int calcHammingDistance(Bitmap _imgA, Bitmap _imgB)
{
string dHashA = calcPerceptualDhash(_imgA);
string dHashB = calcPerceptualDhash(_imgB);
int hamming = calcHammingDistance(dHashA, dHashB);
return hamming;
}
}
使用する際はこんな感じに呼んでもらうと使えます。
static void main()
{
CompareImage ci = new CompareImage();
Bitmap imgA = new bitmap("hogehoge_A.png");
Bitmap imgB = new bitmap("hogehoge_B.png");
int distance = ci.calcHammingDistance(imgA, imgB);
Console.Write(distance);
}
多分コピペで使用できるかと。
雑感
と言うわけで、画像比較の方法でした。
色々ググってやり方自体は出てくるものの、C#でのサンプルプログラム自体は少なかったり、そもそも機械学習による類似度算出方法等、高速で処理できないものでしたので、いっそのこと自分で1から考えて作ってみました。
時間はかかりましたが、結構いい勉強になったと思います。
また、この記事が他の誰かにも役立つことを祈って・・・
参考サイト様
コメント