today   2019-03-02

access_time  7 mins

splatoon画像認識について

プライベートマッチの自動ボイスチャット振り分けbot

最近Discordを使ってボイスチャットをしながらSplatoonをしている。そこでSplatoonは4vs4の対抗戦のためボイスチャットもチームごとに分けたい。 そこで手動で映るのは面倒なので自動化した。その備忘録。

実装は .NET Core 2.1(C#) + OpenCVSharp + Discord.NETでやった。OpenCVSharpが非Win環境でうまく動かず、当初の思惑から外れてしまって少し残念。

ikanopu image ikanopu - Github

名前認識

screenshot

次のような画像が来るので、名前とDiscord IDをうまく結び付けられればあとはDiscord側のAPIをうまいこと叩いてあげればうまくいきそうである。

ただ、すでに書いてある枠を見てもらえればわかるが、枠は微妙に傾いてあるため元画像とのxorなどではうまくいかない。((傾き補正まで含めて実装すればうまくいきそうではある))

そこで局所特徴量を比較することで実装した。

画像取得

OpenCVのVideoCaptureは、USBデバイスなどから画像を取得することができる。便利。

/ikanopu/Service/ImageProcessingService.cs#L61

追加でUSB以外のネットワークエンドポイントを指定したストリームも見れるようにしてある。

 using (var capture = new VideoCapture() { }) {
    capture.Open(CaptureDevice.Any, Config.CameraIndex);
    capture.FrameWidth = Config.CaptureWidth;
    capture.FrameHeight = Config.CaptureHeight;

    // ゴミが入っているので最初に読んでおく
    capture.Read(this.CaptureRawMat);
    while (!cancellationToken.IsCancellationRequested) {
        capture.Read(this.CaptureRawMat);
    }
}

videocapture

背景の削除

名前はほぼ白で書いているので、Grayscale→しきい値固定の2値化処理でうまくいく。

/ikanopu/Core/ImageUtil.cs#L128

public static IEnumerable<(CropOption.Team, Mat)> RemoveBackground(
    this IEnumerable<(CropOption.Team, Mat)> src,
    double threash = 200,
    double max = 255) {
    foreach (var (t, m) in src) {
        using (var cvt = m.CvtColor(ColorConversionCodes.RGB2GRAY)) {
            var dst = cvt.Threshold(threash, max, ThresholdTypes.BinaryInv);
            yield return (t, dst);
        }
    }
}

binary

特徴量抽出

AKAZE,FAST等有名所をいくつか試したが、解像度があまり高くないことや街中写真から看板を見つけるようなマッチングとは少々勝手が異なるようでうまくいかず。結局周辺への重み付けをよしなにやっているBRISKが一番いい成果を上げた。 詳細はわたしよりも大学の講義テキストに準じたpdfをGoogleで調べたほうが参考になる。

/ikanopu/Core/ImageUtil.cs#L152

public static void Compute(this Mat mat, out KeyPoint[] keyPoint, Mat descriptor) {
    if (computeEngine == null) {
        computeEngine = BRISK.Create();
    }
    computeEngine.DetectAndCompute(mat, null, out keyPoint, descriptor);
}

こんなに簡単にできていいのだろうか…OpenCV様様である。

特徴識別

まず事前に登録した画像との特徴量の結果、具体的にはKeyPointの数と2画像間の距離を一通り計算する。

matching

これをいい感じな評価関数でマッチングしてあげればよいのだが、人間が行うような手法をそのまま落とし込んでしまっているので参考にはならなそう。実際には動かしながら実装としきい値を決め打ちしている。

行っている処理は以下の通り。

1.取得した画像の特徴量を計算

2.すでに保存されている画像との特徴量差分を一通り計算する

抽出するメソッドから一部切り出した。distanceはmatches(KeyPoint対応のリスト)からその距離の平均を計算している。 またkeypointDiffという変数で、特徴点数の差分もペナルティとして勘定に入れている。

これを入れないと、特徴点数が異様に多い画像と少ない画像で比較を行った際のmatchesが全体的に小さい値を推移するからである。

/ikanopu/Core/ImageUtil.cs#L177

var (kp, d) = user.ComputeData;
return (user, computeDatas.Select(target => {
    var matches = target.KeyPoints.Length == 0 ? new DMatch[] { } : matcher.Match(d, target.Descriptor).ToArray();
    // 距離の総和だけでは不十分なので、KeyPointの数量の差をペナルティとして入れる
    var distance = matches.Length == 0 ? 0 : matches.Average(m => m.Distance);
    var keypointDiff = Math.Abs(target.KeyPoints.Length - matches.Length) / (double)matches.Length + 1.0;
    var score = distance;

    return new {
        // 元データ
        Team = target.Team,
        Image = target.Image,
        KeyPoints = target.KeyPoints,
        // 計算後のデータ
        Matches = matches, // 一致した点の数。これは多いほうが良い
        Score = distance, // 小さいほどよい。KeyPointsがなければそもそも0になるのでそこだけ注意
        Distance = distance,
        KeyPointDiff = keypointDiff,
    };
}));

3. 2.項の結果から、飛び抜けたものを抽出

細かいことは雑なコメントに書いてあるが、まとめると「正規分布のはずれにあるものを採用する」だけである。

/ikanopu/Core/ImageUtil.cs#L207

var user = r.user;
var datas = r.Item2;
// まず8箇所あるよね
if (datas.Length < 8) return null;
// alphaとbravoの1人目は取得できてるよね
if (datas[0].Matches.Length == 0 || datas[4].Matches.Length == 0) return null;
// alpha0~3, bravo0~3の途中にZeroが挟まっていた場合→プラベの画面ではない可能性が高い
// ex) 100, 0(2人目だけマッチングが欠落することはありえない), 200, 300
for (int i = 0; i < 2; ++i) {
    // alpha
    if (datas[1 + i].Matches.Length == 0 && datas[2 + i].Matches.Length > 0) return null;
    // bravo
    if (datas[4 + i].Matches.Length == 0 && datas[5 + i].Matches.Length > 0) return null;
}
// 0を除外した値で平均と偏差を計算
// 一致画像は平均値-2sigmaを推移するため、これを満たす画像が1枚だけ見つかるときは真
var src =
    datas.Select((x, i) => new { Index = i, Value = x })
            .Where(x => x.Value.Matches.Length > 0)
            .ToArray();
var sum = src.Sum(x => x.Value.Score);
var average = sum / (double)src.Length;
var sigma = Math.Sqrt(src.Sum(x => Math.Pow(x.Value.Score - average, 2)) / src.Length);
var threash = average - sigma * config.RecognizeSigmaRatio; // 一応sigmaユーザーが指定できる
                                                            // threashを下回ったもののみ抽出
var detects =
    src.Where(x => x.Value.Score <= threash)
        .Where(x => x.Value.KeyPointDiff < config.KeyPointCountThreash) // 特徴数が違いすぎるものは除外
        .OrderBy(x => x.Value.Score)
        .ToArray();

//検出できなかった
if (detects.Length == 0) return null;
// 複数ある場合
var multipleDetect = detects.Length > 1;
if (multipleDetect && !config.IsPrioritizeDetect) return null;
// こいつが正解、複数検出対策にKeyPointDiffの推移も見てやる
var detect =
    detects.OrderBy(x => x.Value.KeyPointDiff).First();

なんとも人間味溢れた処理だが、そんなこんなで先の画像認識が実現する。

recognize

実運用時に認識を誤る場合「もともと登録された画像と異なる特徴を持っている」ので、追加登録することで認識精度を改善することができる。なんとも運用でカバーな話だ。

Discord.NETでのボイスチャンネルの移動

以下の処理でうまく行った。GuildUserを取得することがミソ(単純にユーザ取得しても、ギルドに対する処理は行えない。) それにしてもDiscord.NETはかなり洗練されたライブラリだった。

/ikanopu/Module/PuModule.cs#L565

[Command("move"), Summary("ボイスチャンネル移動テスト")]
public async Task Move(
    [Summary("ユーザID及び名前など(@hogehoge, hogehoge#1234, raw_id)")] IUser user,
    [Summary("ボイスチャンネルID")] IVoiceChannel vc
    ) {
    var guildUser = await Context.Guild.GetUserAsync(user.Id);
    await guildUser.ModifyAsync(x => x.Channel = Optional.Create(vc));
}

※ギルドに属するユーザーの編集権限が必要である。

まとめ

けっこう面倒な課題だったような気がするが、原始的な実装でうまく動いて正直自分でも驚いている。

ikanopuに並行して2つに分けた両チームのボイスチャンネル音声をMixするbotもプロ両名(@taniho_0707, @tokoro10g)を中心に開発され、観戦者は双方の駆け引きと指揮を聞けるようになってより楽しめるようになった。

スプラトゥーンは音・ビジュアル・ゲームシステム等どれをとっても完成度が高く、ユーザーを楽しませてくれる素晴らしいゲームだと思う。