技術屋卵の足跡

いつか振り返ったときにどれだけ這いずり回ったかを確認したい

OpenCVでPCとじゃんけんをする

またまたOpenCVSharpの話。今回は実装例というよりもアルゴリズムの説明に近いのでPythonとかC++とかにも生かせる気がする。

画像処理において、手の認識なり指の認識は結構難問であるらしく、深層学習などを用いて実装している例をよく見かける。 今回は機械学習をしないでも指の本数は簡単に数えられるというお話*1

多分これを行うにあたって一番簡単に理解できるのが次の図。

f:id:pfpfdev:20200715170522p:plain
処理のイメージ

要は手の中心を検出して、そこからドーナッツ型のものと手の交差した数を数えるだけ。あまりにも単純なのでどこかに記事があるかもしれないけれど、少なくとも自分は日本語の記事を見つけられなかったので...自慢げに書いてるけど既出だったり有名だったりしたら恥ずかしい。

詳細

手の切り出しとかこれから説明する方法などは、環境による影響が大きいので実際に試しながら調整する必要がある。 実装の際のアイデア程度に考えてもらえると嬉しい。

実装はOpenCVSharpを使ったので、ここに載せるコードもほぼC#な疑似コードにするが、基本的にOpenCVの関数しか使ってないので、ほかの言語にも一瞬で移植できると思う。

手のひらの中心検出

実際に実装する際は、適当に決めてしまっても案外うまくいったので、選択肢を紹介する。

  1. 2値化した画像から輪郭抽出して、その中心にする
  2. モーメントから重心を計算
//Blobを作成
var blobs = new CvBlob();
blobs.label(binary);
//最大の輪郭の中心を得る
Center = blobs.GreaderBlob().Centroid;

前者はきれいに2値化できていないと、手が一つの輪郭とならないのでうまくいかないし、手の輪郭を見分ける方法が存在する必要がある。 まあ多くの場合は面積最大で絞ってしまっていいと思う。

const double k = 0.5;
//モーメントを計算
var m = Cv2.Moments(binary);
//モーメントから計算した重心を重み付き平均で反映する
var Center = new Point(Center.X*k + (1-k)m.M10/m.M00, Center.Y*k + (1-k)m.M01/m.M00);

後者は白黒画像さえあればそれなりの精度が得られるが、ノイズなどの影響をかなり受けるので、そちらの対策が必要。 多分一番簡単なのは時間平均をとってレスポンスを遅くすること。

円の半径の決定

これもそれなりに適当でも十分な精度が出る。ただしグーの状態とパーの状態である程度大きさが同じでないと、さらなる工夫が必要になったりうまく計算できなくなったりする可能性があるので注意。

  1. 決め打ちパラメータ
  2. 手の面積から半径を計算
  3. 輪郭抽出した点の集合から計算する

1はとっても簡単で設定した環境では精度も非常にいいが、いかんせん汎用性は皆無である。

2は指の半分から先の面積が指の間の面積に近いという、めちゃくちゃアバウトな近似で、手の面積そのものを円の面積と考え半径を計算したが、意外と悪くない精度で計算できる。ただし手の形に応じて大きさが変わってしまうので、一工夫が必要。

3は中心と輪郭上の点の距離の平均を計算する。これも2と同様。

半径を適当に決めても精度があげられる対策は後で説明する。

イメージ画像の水色部分の抽出

//マスク画像の生成
Mat mask = new Mat(new Size(frame_w, frame_h), MatType.CV_8U, new Scalar(0));
//マスクに太い線で円を描く
Cv2.Circle(mask, handCenter.X, handCenter.Y, radius, new Scalar(255), 20);
Cv2.Circle(mask, handCenter.X, handCenter.Y, radius*1.2 , new Scalar(255), 20);
//マスクをかける
Cv2.BitwiseAnd(binary, mask, output);

マスクを作って手の画像と交差する部分を計算する。 ここでマスクの円を複数個用意すれば、精度を上げることができる。 要は、半径がうまく定まらなかったり、中心がちょっと変な所にあってたりしても、判定に範囲を持たせることができるので誤差が吸収できるようになる。

水色部分の数え上げ

//輪郭を抽出する
Cv2.FindContours(output, out contours, out hierarchy, ContourRetrieval.External, ContourChain.ApproxSimple);
int count = 0;
for (int i = 0; i < contours.Length; i++)
{
    var area = Cv2.ContourArea(contours[i]);
    //適切なサイズのものがあったら指の影として数える
    if (3000 > area && area > 1000)
    {
        count++;
    }
}

輪郭を抽出して数を数える。ここで面積が適当な範囲にあるかチェックすることで手首などの無駄な部分を数え上げずにすむ。

以上、このcountの数に応じて指の本数を設定すれば目的達成。

実装例と動作例

これまでの説明を一つのコードにまとめると次のような感じになる。 本質的でない部分を省いているのでコピペでは動かないと思うが、適切に変数を定義して環境依存の部分を調整すればうまくいくと思う。

//frameが元の画像とする
using(var frame = new Mat())
using(var grayFrame = new Mat(new OpenCvSharp.CPlusPlus.Size(width, height), MatType.CV_8UC1, new Scalar(0)))
using(var binary = new Mat(new OpenCvSharp.CPlusPlus.Size(width, height), MatType.CV_8UC1, new Scalar(0)))
{
    // 色ベースでの手の抽出-------------------------------------------
    // 背景白、素手を抽出する、今回の記事にとって本質的でないのでかなり適当

    // グレースケールへ変換
    // 赤チャンネルの分離が一番うまくいった
    Cv2.Split(frame)[0].CopyTo(grayFrame);
    //ノイズなどの対策にぼかす
    Cv2.GaussianBlur(grayFrame, grayFrame, new OpenCvSharp.CPlusPlus.Size(15, 15),10);

    //2値化をうまく行う為のパラメータは環境依存
    Cv2.Threshold(grayFrame, binary, grayFrame.Mean()[0] * 0.8, 255, ThresholdType.BinaryInv);
    
    // ここから記事のトピック ------------------------------------------

    //輪郭抽出
    HierarchyIndex[] hierarchy;
    OpenCvSharp.CPlusPlus.Point[][] contours;
    Cv2.FindContours(binary, out contours, out hierarchy, ContourRetrieval.External, ContourChain.ApproxSimple);

    //切り出した輪郭のうち、面積が最大のモノを手の輪郭と考えてメンバ変数に格納する
    if (contours.Length > 1)
    {
        //最大の輪郭を抽出する
        //今後のためにlargestは配列のほうが都合がいい
        OpenCvSharp.CPlusPlus.Point[][] largest = {contours[0]};
        double max = 0;
        for (int i = 0; i < contours.Length; i++)
        {
            var area = Cv2.ContourArea(contours[i]);
            if (max < area)
            {
                largest[0] = contours[i];
                max = area;
            }
        }
        //手は一定以上の大きさ(環境依存)
        if (max > 300000)
        {
            using(Mat mask1 = new Mat(new OpenCvSharp.CPlusPlus.Size(width, height), MatType.CV_8U, new Scalar(0)))
            using(Mat mask2 = new Mat(new OpenCvSharp.CPlusPlus.Size(width, height), MatType.CV_8U, new Scalar(0)))
            {
                //輪郭を手形のマスクとして書き出す
                Cv2.DrawContours(mask1, largest, -1, new Scalar(255), -1);
                //手の中心から2つ円をマスクとして書き出す
                Mat mask2 = new Mat(new OpenCvSharp.CPlusPlus.Size(width, height), MatType.CV_8U, new Scalar(0));
                //何らかの方法でこの2つを計算する(環境依存)
                var radius = Radius(largest[0]);
                var handCenter = Center(largest[0]);
                //1つの円で検出している記事が多かったが、2つにすることで指の検出できない場合や過剰検出の誤差を吸収できる
                Cv2.Circle(mask2, handCenter.X, handCenter.Y, (int)radius, new Scalar(255), 20);
                Cv2.Circle(mask2, handCenter.X, handCenter.Y, (int)radius - 100, new Scalar(255), 20);
                //2つのマスクの共通部分を書き出す
                Cv2.BitwiseAnd(mask2, mask1, mask1);
                //中心を描画
                Cv2.Circle(frame, handCenter, 10, new Scalar(255, 0, 0), -1);
                //マスクの輪郭を数える
                Cv2.FindContours(mask1, out contours, out hierarchy, ContourRetrieval.External, ContourChain.ApproxSimple);
                int count = 0;
                for (int i = 0; i < contours.Length; i++)
                {
                    var area = Cv2.ContourArea(contours[i]);
                    //適切なサイズのものがあったら指の影として数える(環境依存)
                    if (3000 > area && area > 1000)
                    {
                        count++;
                        //検出点を表示
                        Cv2.Circle(frame, Center(contours[i]), 5, new Scalar(0, 0, 255), -1);
                    }
                }
                //数に応じて判定をする(環境依存)
                if (count < 4)
                {
                    Hand = HandType.Rock;
                }
                else if (count < 6)
                {
                    Hand = HandType.Caesar;
                }
                else if (count < 10)
                {
                    Hand = HandType.Paper;
                }
            }
        }
    }
}

このコードを基にGUIにまとめると次の動画のように動作する*2

*1:ただし汎用的なパラメータは非常に難しい

*2:じゃんけんの実装が適当すぎるけど