やさしい画像認識教室～ゲーム編～

※内容のPowerPointによるスライド版→**http://www.slideshare.net/YSRKEN/ss-54096700**

#概要
　画像から情報を読み取る**画像認識**は、今や様々な場面で当たり前のように使われています。具体的には、

* 画像から文字情報を読み取る**OCR技術**
* ロボットが物体を認識して操作・検査などを行う**ロボットビジョン**
* 指紋や虹彩などから個人を識別する**生体認証**

など、挙げていけばキリがありません。昨今では[Deep Learning](http://www.slideshare.net/beam2d/deep-learning-52872945)による高性能な画像認識技術も登場し、その**[認識率は人間を超える](http://www.4gamer.net/games/999/G999902/20150829007/)**ほどにまでなりました。
　とはいえ。簡単な画像認識の処理を書くぐらいでしたら、高度な数学を用いなくても実装できます。以下、**実例をもって**そのことを示したいと思います。

#判定に用いる素材および条件
画像：**[艦これ](http://www.dmm.com/netgame/feature/kancolle.html)の戦闘画面(昼戦)**。 デフォルトの画像サイズが決まっていて、3Dゲームでもないので判定しやすいのが主な採用理由。以下の記述においては、**画像サイズが800x480であることを前提**にしています。
![戦闘画面](http://i.imgur.com/U9iCc0h.png)
判定内容：**自艦隊および敵艦隊の「艦船数」「艦名」「損害判定」**。上記内容で言えば、「4隻と2隻」「暁改・響改・雷改・電改と駆逐ロ級・駆逐イ級」「全て無傷と全て撃沈」と答えるのが正解。
使用言語：Java。手っ取り早くGUI出せるし画像読めるし色々都合がいいから。

#まずは外形を実装してみる

```sample1.java
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import javax.imageio.ImageIO;
import javax.swing.JFrame;
import javax.swing.JOptionPane;
import javax.swing.JPanel;

public class sample1 extends JPanel{
	static BufferedImage image;
	// main関数
	public static void main(String args[]){
		if(args.length < 1) return;
		try{
			// 画像を読み込む
			image = ImageIO.read(new File(args[0]));
			// フレームを作成して表示する
			JFrame frame = new JFrame("テスト");
			sample1 panel = new sample1();
			frame.getContentPane().add(panel);
			frame.getContentPane().setPreferredSize(new Dimension(image.getWidth(), image.getHeight()));
			frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
			frame.setVisible(true);
			frame.pack();
			// 画像を読み取って情報を表示する
			JOptionPane.showMessageDialog(frame, getInfo(image));
		}
		catch(IOException error){
			error.printStackTrace();
		}
	}
	// 画像を読み取って情報を表示する
	private static String getInfo(BufferedImage image){
		String info = "";
		/* 認識処理 */
		return info;
	}
	// 画面を描画する
	public void paintComponent(Graphics graphics){
		graphics.drawImage(image, 0, 0, this);
	}
}
```
　まあ、そんなに難しくはないはずですね。画像を読み込んでフレーム上のパネルに表示しているだけです。
　以降は、**/* 認識処理 */**の部分に追記していくことで画像を認識し、結果を表示します。

#認識の基本はテンプレートマッチング
　テンプレートマッチングとは、その名の通り**比較対象となるテンプレートを事前に用意して、それと比較することにより判定する手法**のことを指します。非常にシンプルですが、今回のように**対象となる絵の種類や形状が決まっている場合**では正確かつ高速に判定できます。
##艦船数
　まず艦船数の判定ですが、幸い**それぞれの枠が出る場所は固定されています**ので、枠の色をカラーピッカー等で読み取ってやれば容易に判断可能です。「カラーピックして指定色と十分に近いと言えるかを判定する関数」を別途作っておくと楽だと思われます。具体的にはこんな感じ。

```sample2.java
static final int cc_threshold = 500;	//カラーピックした際のしきい値(経験上決めた)
// カラーピックして指定色と十分に近いと言えるかを判定する
private static boolean checkColor(int x, int y, int color){
	int argb = image.getRGB(x, y);
	int r_diff = ((argb & 0xFF0000) >> 16) - ((color & 0xFF0000) >> 16);
	int g_diff = ((argb &   0xFF00) >>  8) - ((color &   0xFF00) >>  8);
	int b_diff =  (argb &     0xFF)        -  (color &     0xFF);
	int diff = r_diff * r_diff + g_diff * g_diff + b_diff * b_diff;
	if(diff < cc_threshold) return true; else return false;
}
// 画像を読み取って情報を表示する
private static String getInfo(){
	String info = "";
	/* 認識処理 */
	// 艦船数
	int[] fleet = {1, 1};
	for(int k = 5; k > 0; k--){
		if(checkColor(33, 119 + 45 * k, 0x747572)){
			fleet[0] = k + 1;
			break;
		}
	}
	for(int k = 5; k > 0; k--){
		if(checkColor(686, 188 + 45 * k, 0x747572)){
			fleet[1] = k + 1;
			break;
		}
	}
	info += "艦船数：" + fleet[0] + "/" + fleet[1] + "\n";
	return info;
}
```

##艦名
　次に艦名ですが、**どの辺りをどれぐらい読み取って判断するか**が大きな問題になります。もちろん、キャプ画像を艦娘＋深海棲艦の数だけ一通り用意して逐一比較すれば確実なのですが、どう考えても**重い処理**になりますし、それ以前に**著作権の問題**があります([作っても配布できない](http://idsuru921.blog.fc2.com/blog-entry-37.html)という意味)。
　というわけで、「画像を**水平か垂直にXピクセル**読み取った後2値化したものをDBとして保存しておき、検索する際は連想配列を利用する」という手を使うことにしました。これは、[とある画像認識式タイマー](https://sites.google.com/site/bathtimer/)で使用されているテクニックを真似たものです。艦これの場合は**小破までと中破以上で絵柄が変わる**ので若干ややこしいですが……。仕組みの説明としては、次の図をご覧ください。

![一人前のレディで説明](http://i.imgur.com/V1eReii.png)

```sample3.java
static final double name_threshold = 127.5;	//艦名判定の際のしきい値
static HashMap<Long, String> map = new HashMap<Long,String>();	//艦名検索
// 艦名データを初期化する
map.put(0x000ffffffcL, "暁");	//通常
map.put(0x7ffe7fffffL, "響");	//通常
map.put(0x003fffffffL, "雷");	//通常
map.put(0x00dffffffeL, "雷");	//中破
map.put(0x088be7ffffL, "電");	//通常
map.put(0x80e0000003L, "駆逐イ級");
map.put(0x9be7c00000L, "駆逐ロ級");
private static String getInfo(){
	(中略)
	// 艦名
	info += "自艦隊：";
	for(int k = 0; k < fleet[0]; k++){
		// 40ビットの入力データを取得する
		long unit_data = 0;
		for(int m = 0; m < 40; m++){
			// Yの値を取得する
			int argb = image.getRGB(97, m + 78 + 45 * k);
			int r = ((argb & 0xFF0000) >> 16);
			int g = ((argb &   0xFF00) >>  8);
			int b =  (argb &     0xFF);
			double y = 0.299 * r + 0.587 * g + 0.114 * b;
			// しきい値で判断して、unit_dataに加算する
			unit_data <<= 1;
			if(y >= name_threshold)unit_data += 1;
		}
		if(k != 0)info += "/";
		if(map.containsKey(unit_data)){
			info += map.get(unit_data);
		}else{
			info += "不明";
		}
	}
	info += "\n敵艦隊：";
	for(int k = 0; k < fleet[1]; k++){
		// 40ビットの入力データを取得する
		long unit_data = 0;
		for(int m = 0; m < 40; m++){
			// Yの値を取得する
			int argb = image.getRGB(739, m + 147 + 45 * k);
			int r = ((argb & 0xFF0000) >> 16);
			int g = ((argb &   0xFF00) >>  8);
			int b =  (argb &     0xFF);
			double y = 0.299 * r + 0.587 * g + 0.114 * b;
			// しきい値で判断して、unit_dataに加算する
			unit_data <<= 1;
			if(y >= name_threshold)unit_data += 1;
		}
		if(k != 0)info += "/";
		if(map.containsKey(unit_data)){
			info += map.get(unit_data);
		}else{
			info += "不明";
		}
	}
	info += "\n";
}
```

##損害状況
　まず、損害判定(小破・中破・大破・撃沈)の判定はさほど難しくありません。ただ、**取得できる色データが位置によって微妙に違う**のでHashMap#containsKeyだと正確な判定にならないため、拡張forで一通り調べています。

```sample4.java
static HashMap<Integer, String> damage_map = new HashMap<Integer,String>();	//損害判定
// 損害判定を初期化する
damage_map.put(0xe3d052, "小破");
damage_map.put(0xc9944a, "中破");
damage_map.put(0x6d2e27, "大破");
damage_map.put(0x4b9fd4, "撃沈");
private static String getInfo(){
	(中略)
	// 損害判定
	info += "損害判定：";
	for(int k = 0; k < fleet[0]; k++){
		if(k != 0)info += ".";
		boolean flg = false;
		for(HashMap.Entry<Integer, String> entry : damage_map.entrySet()){
			if(checkColor(140, 105 + 45 * k, entry.getKey())){
				info += entry.getValue();
				flg = true;
				break;
			}
		}
		if(!flg){
			if(checkColor(163, 79 + 45 * k, 0x19FD19)){
				info += "無傷";
			}else{
				info += "軽微";
			}
		}
	}
	info += " / ";
	for(int k = 0; k < fleet[1]; k++){
		if(k != 0)info += ".";
		boolean flg = false;
		for(HashMap.Entry<Integer, String> entry : damage_map.entrySet()){
			if(checkColor(780, 174 + 45 * k, entry.getKey())){
				info += entry.getValue();
				flg = true;
				break;
			}
		}
		if(!flg){
			if(checkColor(636, 148 + 45 * k, 0x19FD19)){
				info += "無傷";
			}else{
				info += "軽微";
			}
		}
	}
}
```


#まとめ
　以上全てを含んだコードをこの記事の文末に置いておきます。コンパイルした後、引数にファイル名を入れて起動すると、画像を読み込んで判定結果を表示します。**判定時間**としては、画像を読み込み終わってからSystem#nanoTimeで計測して**約600マイクロ秒**ですので十分軽いでしょう(i7-4790K・64bitWin10で計測)。大したコードは書いてませんが、結構な情報量が得られることが分かるでしょう。工夫すれば、ついでに昼戦か夜戦か・それぞれの陣形なども分かります。
![判定結果](http://i.imgur.com/u66cC8h.png)

　ただ、現耐久/最大耐久を調べようとすると、少々面倒かもしれません。元の数字画像を用意してパターンマッチング、でいいのですが、そうでない場合は**既存のOCRライブラリを使用する**か、**手本となるデータを用意して機械学習**させるなどで対処する必要があります。そうなると記事がかなり長くなりますので割愛しました。必要になった際に、それぞれ調べてみてください。

#おまけ：全ソースコード
```sample5.java
import java.awt.Dimension;
import java.awt.Graphics;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import java.util.HashMap;
import javax.imageio.ImageIO;
import javax.swing.JFrame;
import javax.swing.JOptionPane;
import javax.swing.JPanel;

public class sample1 extends JPanel{
	static final int cc_threshold = 500;		//カラーピックした際のしきい値(経験上決めた)
	static final double name_threshold = 127.5;	//艦名判定の際のしきい値
	static BufferedImage image;			//読み込んだ画像
	static HashMap<Long, String> unit_map = new HashMap<Long,String>();	//艦名検索
	static HashMap<Integer, String> damage_map = new HashMap<Integer,String>();	//損害判定
	// main関数
	public static void main(String args[]){
		if(args.length < 1) return;
		try{
			// 画像を読み込む
			image = ImageIO.read(new File(args[0]));
			// フレームを作成して表示する
			JFrame frame = new JFrame("テスト");
			sample1 panel = new sample1();
			frame.getContentPane().add(panel);
			frame.getContentPane().setPreferredSize(new Dimension(image.getWidth(), image.getHeight()));
			frame.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
			frame.setVisible(true);
			frame.pack();
			// 艦名データを初期化する
			unit_map.put(0x000ffffffcL, "暁");	//通常
			unit_map.put(0x7ffe7fffffL, "響");	//通常
			unit_map.put(0x003fffffffL, "雷");	//通常
			unit_map.put(0x00dffffffeL, "雷");	//中破
			unit_map.put(0x088be7ffffL, "電");	//通常
			unit_map.put(0x83c000000cL, "駆逐イ級");
			unit_map.put(0x9d81e00000L, "駆逐ロ級");
			// 損害判定を初期化する
			damage_map.put(0xe3d052, "小破");
			damage_map.put(0xc9944a, "中破");
			damage_map.put(0x6d2e27, "大破");
			damage_map.put(0x4b9fd4, "撃沈");
			// 画像を読み取って情報を表示する
			JOptionPane.showMessageDialog(frame, getInfo());
		}
		catch(IOException error){
			error.printStackTrace();
		}
	}
	// カラーピックして指定色と十分に近いと言えるかを判定する
	private static boolean checkColor(int x, int y, int color){
		int argb = image.getRGB(x, y);
		int r_diff = ((argb & 0xFF0000) >> 16) - ((color & 0xFF0000) >> 16);
		int g_diff = ((argb &   0xFF00) >>  8) - ((color &   0xFF00) >>  8);
		int b_diff =  (argb &     0xFF)        -  (color &     0xFF);
		int diff = r_diff * r_diff + g_diff * g_diff + b_diff * b_diff;
		if(diff < cc_threshold) return true; else return false;
	}
	// 画像を読み取って情報を表示する
	private static String getInfo(){
		long start = System.nanoTime();
		String info = "";
		/* 認識処理 */
		// 艦船数
		int[] fleet = {1, 1};
		for(int k = 5; k > 0; k--){
			if(checkColor(33, 119 + 45 * k, 0x747572)){
				fleet[0] = k + 1;
				break;
			}
		}
		for(int k = 5; k > 0; k--){
			if(checkColor(686, 188 + 45 * k, 0x747572)){
				fleet[1] = k + 1;
				break;
			}
		}
		info += "艦船数：" + fleet[0] + "/" + fleet[1] + "\n";
		// 艦名
		info += "自艦隊：";
		for(int k = 0; k < fleet[0]; k++){
			// 40ビットの入力データを取得する
			long unit_data = 0;
			for(int m = 0; m < 40; m++){
				// Yの値を取得する
				int argb = image.getRGB(97, m + 78 + 45 * k);
				int r = ((argb & 0xFF0000) >> 16);
				int g = ((argb &   0xFF00) >>  8);
				int b =  (argb &     0xFF);
				double y = 0.299 * r + 0.587 * g + 0.114 * b;
				// しきい値で判断して、unit_dataに加算する
				unit_data <<= 1;
				if(y >= name_threshold)unit_data += 1;
			}
			if(k != 0)info += "/";
			if(unit_map.containsKey(unit_data)){
				info += unit_map.get(unit_data);
			}else{
				info += "不明";
			}
		}
		info += "\n敵艦隊：";
		for(int k = 0; k < fleet[1]; k++){
			// 40ビットの入力データを取得する
			long unit_data = 0;
			for(int m = 0; m < 40; m++){
				// Yの値を取得する
				int argb = image.getRGB(737, m + 147 + 45 * k);
				int r = ((argb & 0xFF0000) >> 16);
				int g = ((argb &   0xFF00) >>  8);
				int b =  (argb &     0xFF);
				double y = 0.299 * r + 0.587 * g + 0.114 * b;
				// しきい値で判断して、unit_dataに加算する
				unit_data <<= 1;
				if(y >= name_threshold)unit_data += 1;
			}
			if(k != 0)info += "/";
			if(unit_map.containsKey(unit_data)){
				info += unit_map.get(unit_data);
			}else{
				info += "不明";
			}
		}
		info += "\n";
		// 損害判定
		info += "損害判定：";
		for(int k = 0; k < fleet[0]; k++){
			if(k != 0)info += ".";
			boolean flg = false;
			for(HashMap.Entry<Integer, String> entry : damage_map.entrySet()){
				if(checkColor(140, 105 + 45 * k, entry.getKey())){
					info += entry.getValue();
					flg = true;
					break;
				}
			}
			if(!flg){
				if(checkColor(163, 79 + 45 * k, 0x19FD19)){
					info += "無傷";
				}else{
					info += "軽微";
				}
			}
		}
		info += " / ";
		for(int k = 0; k < fleet[1]; k++){
			if(k != 0)info += ".";
			boolean flg = false;
			for(HashMap.Entry<Integer, String> entry : damage_map.entrySet()){
				if(checkColor(780, 174 + 45 * k, entry.getKey())){
					info += entry.getValue();
					flg = true;
					break;
				}
			}
			if(!flg){
				if(checkColor(636, 148 + 45 * k, 0x19FD19)){
					info += "無傷";
				}else{
					info += "軽微";
				}
			}
		}
		long stop = System.nanoTime();
		System.out.println(stop - start);
		return info;
	}
	// 画面を描画する
	public void paintComponent(Graphics graphics){
		graphics.drawImage(image, 0, 0, this);
	}
}
```