MATLABによるChromeの恐竜のアレ

# 1. 概要
Chromeでオフライン時、あるいはアドレスバーに"chrome://dino/-109"を入力したときに遊べる、恐竜がサボテンやプテラノドンを避けながら走るゲーム「Dino Run」。
![Desktop 2020.12.13 - 02.37.10.23_Trim.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/641b05d9-b122-1110-1505-ec4a8f8a535a.gif)

Vivaldiでも"vivaldi://dino/-109"にアクセスすることで、同様のゲームを遊ぶことができます。
~~縦長Tonyくんキモすぎひん・・・？~~
![3_Trim_Trim_Trim.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/f9a7fb70-1bec-f33e-492e-04787b6199e3.gif)
操作はスペースキーでジャンプ(押下時間により高さが変動)、↓キーでしゃがみ(+落下速度向上)です。

今回は、MATLABで

* スクリーンショットを取得しゲームの状態を把握
* ゲームの状態に従い適切なタイミングでスペースキーを押下

を行い、このゲームを自動プレイするコードを作成したので紹介します。

**なお、高度に政治的な判断によりブラウザはVivaldiを使用します。**

# 2. スクリーンショットとキーボードイベントの生成
MATLABからスクリーンショットとキーボードイベントを発生させるためには、java.awt.Robotクラスを使用します。
[参考：スクリーンショット取得](https://jp.mathworks.com/matlabcentral/answers/362358-how-do-i-take-a-screenshot-using-matlab)
[参考：キーボードシミュレート](https://jp.mathworks.com/matlabcentral/answers/5259-how-to-simulate-keyboard-key-inputs)
## 2.1 スクリーンショットの取得
スクリーンショットは`java.awt.Robot.createScreenCapture`関数で取得することができます。引数に`java.awt.Rectangle`で矩形領域を指定することで、ウィンドウ上の特定範囲のスクリーンショットを取得します。矩形領域はウィンドウ左上隅を基準に、`left`、`top`、`height`、`width`をそれぞれ指定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/bb180b85-39b5-13b1-5586-41581c4d262c.png)

取得したスクリーンショットは`getRGB`メソッドでint32型の配列として取り出すことができます。この配列はスクリーンショット取得領域を行優先で展開したもので、各1バイトごとにRGBAの値がBGRAの順で格納されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/54b4fa2a-50f5-2063-cb14-f2f7335c0b17.png)

``` matlab:スクリーンショット取得用コードのサンプル
% java.awt.Robotクラスのインスタンスを定義
rbt = java.awt.Robot;
% スクリーンショット取得領域の定義
cap_left = 0;
cap_top = 0;
cap_width = 640;
cap_height = 480;
cap_rect = java.awt.Rectangle(cap_left , cap_top , cap_width , cap_height );

% スクリーンショット取得
cap = rbt.createScreenCapture(cap_rect);
% RGBA値の取得
rgb = cap.getRGB(0,0,cap.getWidth,cap.getHeight,[],0,cap.getWidth);

%% 画像の表示
% int32からuint8へキャストし各チャネルの値を分解
rgb = typecast(rgb, 'uint8');
% 画像データの作成
imgData = zeros(cap.getHeight,cap.getWidth,3,'uint8');
imgData(:,:,1) = reshape(rgb(3:4:end),cap.getWidth,[])';
imgData(:,:,2) = reshape(rgb(2:4:end),cap.getWidth,[])';
imgData(:,:,3) = reshape(rgb(1:4:end),cap.getWidth,[])';
% 画像表示
imshow(imgData);
```
## 2.2 キーボードイベントの発生
キー押下イベントは`java.awt.Robot.keyPress`関数、キー解放イベントは`java.awt.Robot.keyRelease`関数で発生させることができます。キーコードは`java.awt.event.KeyEvent.VK_xxx`で定義されています([キーコード一覧](https://docs.oracle.com/javase/jp/8/docs/api/java/awt/event/KeyEvent.html))。

``` matlab:キーボードイベントのサンプルコード
% java.awt.Robotクラスのインスタンス定義
rbt = java.awt.Robot;

% スペースキーのキーコード
key_event_space = java.awt.event.KeyEvent.VK_SPACE;

% スペースキーを約0.25秒押下して解放
rbt.keyPress(key_event_space);
pause(0.25);
rbt.keyRelease(key_event_space);
```

# 3. 実装
## 3.1 固定タイミング、固定ジャンプ量
まずは単純に、障害物(サボテン、プテラノドン)を検出する矩形領域を事前に定めておき、この領域内に障害物が進入したときにスペースキーの押下、解放イベントを発生させます。

検出領域の高さは地面とキャラクタの身長、横位置はジャンプまでの猶予時間をみながら試行錯誤で決定しています。
![Screenshot from 2020-11-23 19-20-22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/5f6abcf8-6155-afcd-10bb-c1906524275b.png)

障害物検出の方法は、検出領域内では背景色が単色であることを利用し、検出領域内に複数色があるか否かで判定します。

全コードは以下の通りです。サンプルレートは音頭を取っていないため成り行き任せです(実行環境によりジャンプタイミング等が変わります)。

``` matlab:実装(1)
% 環境初期化
clear;
close all;

% java.awt.Robotクラスのインスタンス
rbt = java.awt.Robot;

% スクリーンショット取得領域(障害物検出領域)
cap_width = 20;
cap_height = 35;
cap_rect = java.awt.Rectangle(800, 190, cap_width, cap_height);

% スペースキーのキーコード
key_event_space = java.awt.event.KeyEvent.VK_SPACE;

% 各種イベントはアクティブなウィンドウに対して実行されるため、
% MATLABからブラウザへアクティブウィンドウを変更するための猶予時間
pause(3);

% GAME OVER画面からの復帰(スペースキー押下)
rbt.keyPress(key_event_space);
pause(0.1);
rbt.keyRelease(key_event_space);

% メインループ
while (true)
    % スクリーンショット取得
    cap = rbt.createScreenCapture(cap_rect);
    % RGB値取得
    rgb = cap.getRGB(0,0,cap_width,cap_height,[],0,cap_width);
    
    % 障害物判定(取得画像が単色でなかったら)
    if ( length(unique(rgb)) ~= 1 )
        % 0.25秒間スペースキーを押下する
        rbt.keyPress(key_event_space);
        pause(0.25);
        rbt.keyRelease(key_event_space);
    end
end
```

実行してみた結果を以下の動画に示します。スコアは1028点、序盤は調子がいいですが、障害物間の間隔が狭い場合に次のジャンプが間に合わずゲームオーバー。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/917d4525-c6e6-d01a-a37b-ad9e66f491f8.png)
https://drive.google.com/file/d/1E8ocp7kMwT1w7-sKS0A4AfpuK2bVCfHA/view?usp=sharing

## 2.2 障害物の高さに応じたジャンプ量の調整
上記のように、常に大ジャンプを行っていると次のジャンプが間に合いません。そこで、障害物の背が低い場合はジャンプ量を少なくし、早期に着地できるよう調整を行います。前述のとおりスクリーンショットのRGBA配列は行優先で展開されているため、背景との境界の最小インデックスを`find(diff(rgb), 1)`で求めて高さを概算できます。(嘘です。書いてて気づきましたが障害物下方の左端がギリギリ検出領域に被った場合は実際より低く算出されます。)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/fd877874-8678-4324-4dd4-2329902c0096.png)

``` matlab:実装(2)
% 環境初期化
clear;
close all;

% java.awt.Robotクラスのインスタンス
rbt = java.awt.Robot;

% スクリーンショット取得領域(障害物検出領域)
cap_width = 20;
cap_height = 35;
cap_rect = java.awt.Rectangle(800, 190, cap_width, cap_height);

% スペースキーのキーコード 
key_event_space = java.awt.event.KeyEvent.VK_SPACE;

% 各種イベントはアクティブなウィンドウに対して実行されるため、
% MATLABからブラウザへアクティブウィンドウを変更するための猶予時間
pause(3);

% GAME OVER画面からの復帰(スペースキー押下)
rbt.keyPress(key_event_space);
pause(0.1);
rbt.keyRelease(key_event_space);

% メインループ
while (true)
    % スクリーンショット取得
    cap = rbt.createScreenCapture(cap_rect);
    % RGB値取得
    rgb = cap.getRGB(0,0,cap_width,cap_height,[],0,cap_width);
    
    % 障害物判定(取得画像が単色でなかったら)
    if ( length(unique(rgb)) ~= 1 )
        % 障害物の高さに応じてジャンプ量を調整
        if ( find(diff(rgb), 1) < 200 )
            t_jump = 0.25;
        else
            t_jump = 0.10;
        end
        
        % 調整されたジャンプ量の分、スペースキーを押下する
        rbt.keyPress(key_event_space);
        pause(t_jump);
        rbt.keyRelease(key_event_space);
    end
end
```
実行してみた結果を以下の動画に示します。スコアは1420点。大きく改善できましたが、スクロールスピードの加速に伴いジャンプのタイミングがずれてゲームオーバー。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/72480de8-67d0-7c92-b192-5d1f63e2d1e4.png)
https://drive.google.com/file/d/1U37UxrneF-dH6-jusSkMSQK0MojHb3Aw/view?usp=sharing

## 3.3 スクロールスピードに応じたジャンプタイミングの調整
そこでスクロールの加速に応じてジャンプを早めます。スピードの検出は時間差(0.1秒)を置いて2枚のスクリーンショットを取得し、障害物の移動距離を測ることで行います。移動距離の計算手段は相互相関やLucas-Kanade法などいろいろ考えましたが、サボテンもプテラノドンも鼻先の形状は変化しないため、やや横長のスクリーンショットを取得し、列優先で展開しなおしたRGBA配列の境界の最小インデックスの変化量を求めることでこれに代えます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/c311b725-e23b-9220-8f97-127e9e21417d.png)
ジッタにより検出値が振れることがあったため、さらに1次LPFを適用し、求まった移動距離そのものをスクロールスピード`v_filt`として扱います。適切なジャンプまでのウェイト`t_jump`は、このスピードに対し線形に書けると仮定し、さらにパラメータ調整を容易にするため最序盤のスクロールスピード`v_init`を導入して以下のように算出します。

``` matlab
t_jump = -coeff_1 * (v_filt-v_init) + coeff_0
```
この方法は検出領域内の障害物が唯一であることを仮定しています。従って、ジャンプ処理実行済みの障害物とその次の障害物が同時に検出された場合、ジャンプ処理が二重発生し回避に失敗します。そこで、2回目以降の障害物検出処理の際は直前に取得した2枚目の障害物位置以降を検出に用います(正確には前回の障害物の後端以降とするべきですがジャンプ中にスクロールアウトするので無問題です)。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/7cf9ed46-0e1b-5f8b-bfad-c42654e41ee7.png)


``` matlab:実装(3)
% 環境初期化
clear;
close all;

% java.awt.Robotクラスのインスタンス
rbt = java.awt.Robot;

% スクリーンショット取得領域(障害物検出領域) 
cap_width = 150;
cap_height = 35;
cap_rect = java.awt.Rectangle(890, 190, cap_width, cap_height);

% スペースキーのキーコード
key_event_space = java.awt.event.KeyEvent.VK_SPACE;

% ジャンプタイミング算出用の係数
coeff_1 = 0.0004;
coeff_0 = 0.5;
% 初期のスクロール速度(実測値から概算)
v_init = 1400;

% 速度推定用のフィルタ関連変数 
alpha_v = 0.7 ;
v_filt = v_init;

% 障害物検出待ちフラグ
dct_wait = true;

% 各種イベントはアクティブなウィンドウに対して実行されるため、
% MATLABからブラウザへアクティブウィンドウを変更するための猶予時間
pause(3);

% GAME OVER画面からの復帰(スペースキー押下)
rbt.keyPress(key_event_space);
pause(0.1);
rbt.keyRelease(key_event_space);
% 予測領域が横長なのでGAME OVER画面のリトライボタンと干渉するため消えるまで待機
pause(0.5);

% 同一の障害物の二重検出防止用
pos1 = 1;

% メインループ
while (true)
    % スクリーンショット取得
    cap = rbt.createScreenCapture(cap_rect);
    % RGB値取得
    rgb0 = cap.getRGB(0,0,cap_width,cap_height,[],0,cap_width);
    % 各RGB配列を列優先に変換
    rgb0cm = reshape(rgb0, cap_width, [])';
    rgb0cm = rgb0cm(:);
    
    
    % 障害物判定(取得画像のうち、前回の2枚目における障害物位置以降の部分が単色でなかったら)
    if ( length(unique(rgb0cm(pos1:end))) ~= 1 )
        % 0.1秒待機して新たなスクリーンショットを取得
        pause(0.1);
        cap = rbt.createScreenCapture(cap_rect); 
        rgb1 = cap.getRGB(0,0,cap_width,cap_height,[],0,cap_width);
        
        % 各RGB配列を列優先に変換
        rgb1cm = reshape(rgb1, cap_width, [])';
        rgb1cm = rgb1cm(:);
        
        % 2枚のスクリーンショット内での障害物の先頭位置を取得
        pos0 = find(diff(rgb0cm(pos1:end)), 1) + (pos1-1);
        pos1 = find(diff(rgb1cm), 1);
        
        % フィルタ処理
        v_filt = alpha_v * v_filt + (1-alpha_v) * (pos0-pos1);
        
        % 変位に応じてジャンプタイミングを調整
        pause(-coeff_1*(v_filt-v_init) + coeff_0);
        
        % 障害物の高さに応じてジャンプ量を調整
        if ( find(diff(rgb1), 1) < 200 )
            t_jump = 0.20;
        else
            t_jump = 0.10;
        end
        
        % 調整されたジャンプ量の分、スペースキーを押下する
        rbt.keyPress(key_event_space);
        pause(t_jump);
        rbt.keyRelease(key_event_space);
    end
end
```
実行してみた結果を以下の動画に示します。スコアは1976点。これ以上の改善は↓キーのしゃがみなども取り入れる必要が出てきそうです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/211913/331bbfe9-879c-5bee-f53c-241666fd4d19.png)
https://drive.google.com/file/d/1_1VlDm_SBDV_-P_-SZ42U0m6H9KLBkm7/view?usp=sharing

# 4. 応用
今回はゲームでしたが、もともとこれを調べていた理由はMATLABとのI/Fがないソフトとデータ授受を行うためでした。いろいろと応用(悪用)が効きそうなので機会があればお試しあれ。

# ちな
プニキもやってみた。ロビンまでやりたかったけど球種判別がめんどくさくてサスペンド。機械学習系のToolboxでもうちょっと遊べるかも？

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">MATLABによるプニキ<br>(初期ステ オウル 19/35) <a href="https://t.co/2XLhtf2wTa">pic.twitter.com/2XLhtf2wTa</a></p>&mdash; LQGレギュレータの保証された安定余裕 (@HppyCtrlEngnrng) <a href="https://twitter.com/HppyCtrlEngnrng/status/1286854593045962752?ref_src=twsrc%5Etfw">July 25, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>