LoginSignup
50
56

More than 5 years have passed since last update.

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

Last updated at Posted at 2015-10-18

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

概要

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

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

など、挙げていけばキリがありません。昨今ではDeep Learningによる高性能な画像認識技術も登場し、その認識率は人間を超えるほどにまでなりました。
 とはいえ。簡単な画像認識の処理を書くぐらいでしたら、高度な数学を用いなくても実装できます。以下、実例をもってそのことを示したいと思います。

判定に用いる素材および条件

画像:艦これの戦闘画面(昼戦)。 デフォルトの画像サイズが決まっていて、3Dゲームでもないので判定しやすいのが主な採用理由。以下の記述においては、画像サイズが800x480であることを前提にしています。
戦闘画面
判定内容:自艦隊および敵艦隊の「艦船数」「艦名」「損害判定」。上記内容で言えば、「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;
}

艦名

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

一人前のレディで説明

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で計測)。大したコードは書いてませんが、結構な情報量が得られることが分かるでしょう。工夫すれば、ついでに昼戦か夜戦か・それぞれの陣形なども分かります。
判定結果

 ただ、現耐久/最大耐久を調べようとすると、少々面倒かもしれません。元の数字画像を用意してパターンマッチング、でいいのですが、そうでない場合は既存の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);
    }
}
50
56
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
50
56