技術屋卵の足跡

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

調布祭プラレールのバックエンドの話

UEC koken Advent Calendar 2020 22日目の記事です。

調布祭プラレール企画のブログリレーは以下の通り。

以下コンテンツ


※技術系の記事ではないです。感想文的な

11月末に行った調布祭2020について、みんなで手をつないでアウトプットしましょうねということになったので、今までに書いた記事とは全く毛並みが異なるがとりあえず書いてみた。

調布祭がどうだとかどうゆう企画だとかいう情報はおそらく他の人が書いてくれているので、ここではバックエンドがどうなっているかだけを残す。

作ったもの

工数を最小化するために、決まった仕様を最小限に抑えて実装した。 言語は持ち運びが簡単で、メンバー内部で普及していたGo言語で。

機能としては(非常にありきたりな)以下の感じなバックエンドAPIサーバーを作った。 ネストしたものは構成技術など。

やっていることは比較的多いが、内容は簡単なもの。

  • http側からの通信をsocket通信に送信
  • 操作権管理
  • 動的に生成されるAPI
    • オンメモリDB的なMap
  • Basic認証を用いた権限分離
  • 静的ファイルのサーブ(簡易的)
  • httpで遠隔ログ監視
  • configファイルで簡単管理
  • 同時操作数の表示
    • mutex

要求

要求されたネットワーク構成を非常に簡単に図式化すると以下の通り。

f:id:pfpfdev:20201221225902p:plain
構成

要はめちゃくちゃ強固なFirewallがあってそれを介してユーザーが機器に対して指示を出したいという非常にありきたりな構成。これをうまく扱うためには外部にサーバーを用意して、双方から接続しにいくようなリバースプロキシ的なものを作ればよさそう。

方針

f:id:pfpfdev:20201221230429p:plain
用意するもの

バイス側はESP32やラズパイなどの貧弱なデバイスなので、なるたけ負荷を小さくすべくTCPコネクションを確立してずっと通信する方式に、ユーザー側はUI側との連携からHTTPベースでの実装になることが決まった。

バイスなどが一切完成しないままこのプログラムの作成を始めた感じだったので、このプログラムでは細かいことを決めずに大筋だけ決めてあとはデバイスからの指示を基にAPIを動的に生成するのが楽そうだったので、それができるように実装した。

APIとしてのリファレンス的な

バイス側からSocketでつないで、APIを自動的に作成しそのAPIにHTTPでアクセスすることでデータを橋渡しする。

大まかな流れは、デバイス側がどのようなコマンドを受け付けるかなどを初期化フェーズとしてサーバーに通知し、サーバーはその定義情報を基にAPIを作成、Web側に公開し、操作を受けたら定義されたコマンドとしてデバイスに通知するという感じである。

用語は以下の通り

  • Device
    • ESP32, Raspiなどで動作する通信プログラムのこと
  • Operable
    • 操作可能オブジェクト
    • Operableごとにコマンドを定義する
  • コマンド
    • 操作の単位

例: 調布駅 = Device

一番線ホーム = Operable

停車/発信 = コマンド

各要素から見たプロトコルは以下の通り。ちょうど内部向けに出したREADMEがちょうどよさげだったのでほぼほぼコピペしてみた。使うわけではないので、雰囲気が伝わればいいかな程度に。

IoT機器側

規定のポート番号に対して(8081とか)、TCPコネクションを立てる。 後述するプロトコルにより、http側からの制御を受けることができる。

プロトコル

TCPコネクションを確立した機器は固有の名前持つ。

通信は\nをデリミタとする一行を単位とする。 通信の中では語をで区切るCSV方式で通信する。

実際の通信は以下のような流れで行う。

--- Initial phase

IoT > <NAME>\n
IoT > <CMD> ...<ARGS>\n
IoT > FIN\n

--- Normal phase

Server > <CMD> <ARG>\n
IoT > <RES>\n
...

Initial phaseで実行することができるのは、以下のコマンド

  • ADD [OPERABLE_NAME]
    • 制御対象の機器(operable)を追加する
  • REG [OPERABLE_NAME] [COMMAND_NAME] [ARG_TYPE]
    • 追加済みのoperableにたいして、コマンドを定義する
    • [ARG_TYPE]にはOnOff,Hundredの二種類があり、前者は引数としてOn|Off,[0-9]{2}の文字列が送信される

たとえば次のように送信することで、{LED,speed}と{power}を制御することができるIoT機器が接続されているとみなす。

Device1 
ADD ope1
REG ope1 LED OnOff    
REG ope1 speed Hundred
ADD ope2
REG ope2 power OnOff  
FIN

Normal phaseではIoT機器から情報を受け取ることはせず、http側からの要求に基づいて定義されたコマンドが呼び出される。

上の場合では

ope1 LED On
ope1 speed 50
ope2 Off

などが送信されるので、それをハンドリングしレスポンスとして1行を送信する。

Normal phaseでは一定時間(10秒)以上通信がないIoT機器は落ちたと判断してコネクションを切断する。 それを避けるためには定期的に(3秒程度)\nだけを送信する必要がある。

HTTP側

HTTPサーバーは規定のポート(8080)で実行される。 APIのエンドポイントは以下のようにマッピングされている。

GET /devices
    接続済みのIoT機器一覧を表示する。
    {
        "DeviceName":{
            "Name":"DeviceName",
            "Operables":{
                "OperableName":{
                    "Name":"OperableName",
                    "Operations":{
                        "CommandName":{
                            "Cmd":"CommandName",
                            "Type":"OnOff|Hundred"
                        },...
                    }
                },...
            }
        },...
    }
GET /device/{name}
    デバイスの詳細情報を表示する
    {
        "Name":"DeviceName",
        "Operables":{
            "OperableName":{
                "Name":"OperableName",
                "Operations":{
                    "CommandName":{
                        "Cmd":"CommandName",
                        "Type":"OnOff|Hundred"
                    },...
                }
            },...
        }
    }
GET /units
    ユニットの情報を一覧表示する
    {
        "UnitName":{
            "Name":"UnitName",
            "Operables":{
                "ope1":{
                    "Name":"ope1",
                    "Operations":{
                        "LED":{
                            "Cmd":"LED",
                            "Type":"OnOff"
                        },
                        "speed":{
                            "Cmd":"speed",
                            "Type":"Hundred"
                        }
                    }
                },
                "ope2":{
                    "Name":"ope2",
                    "Operations":{
                        "power":{
                            "Cmd":"power",
                            "Type":"OnOff"
                        }
                    }
                },...
            },
            "Queue":[
                {
                    "LastTime":"2020-11-13T21:33:16.9206603+09:00",//最終アクセス(有効化)時刻
                    "Until":"2020-11-13T21:34:28.7914384+09:00",//制御権の有効期限の目安(割り当てられてないならば意味なし)
                    "IsAlive":true
                },...
            ],
        },...
    }
POST /units
    ユニットの構成を設定する
    以下のフォーマットをBODYで送信する
    BASIC認証が必要
    {
        "UnitName":{
            "DeviceName of operables":["OperableName1","OperableName1"],
            "Other Device":["OperableName1","OperableName1"]
        }
    }
POST /units/{unitName}
    整理番号(制御に必要なトークン)の確保
    ここで確保したら/units/{unitName}で順番確認
    定期的にtokenの有効化を行う
    {
        "Token":randomuint64
    }
GET /units/{unitName}?token={token}
    tokenの更新を行う
    定期的にここにアクセスしTokenの有効化を行う(10~30秒に一度程度)
    返り値は現在の順番(1が先頭)
    {
        "Order":1
    }    
    トークンが指定されなければユニットに関する情報を取得する
    この場合の返り値は/unitsの限定的なものにつき省略
GET /units/{unitName}/{operableName}?cmd={cmdName}&arg={arg}
    操作を行う
    unitsのUserのIdと同じtokenの人が操作できる
GET /log?offset={offset}
    ログの取得
    Offsetを指定すると続きを読み取れる
    ラウンドロビンとか考えてないので定期的に再起動したほうがいいかもしれない{要検証}
    BASIC認証が必要
    5秒に一度くらい読み取って、logファイルへのアクセスログをフィルタリングすれば良い気が
    {
        "Log":"LogText\n",
        "Offset":123
    }

まとめ

今回のAPIはGo言語で作成したので、基本的に上のような仕様や使い方が決まれば、おおよその実装は自明となる。 特に技術的に難しいこともなく、引っ掛かりポイントすらまともにないような単純なプログラムなので、ソースコードを載せたり解説したりするのはしない。

ただこれだけでは味気ないので、気を使った点を最後にまとめておく。

データの管理方法

今回のシステムではデバイス側と常に通信をする必要があるので、デバイスのConnベースでデータの管理を行った。

Connが作成されたら、上のプロトコルで通信し適当に情報を付け加えたものをオンメモリのDBもどきとしてMapに保存し、通信が切れたらMapから削除するようにしている。

バイス側とはCSV的な文字列で通信し、ユーザー側とはjsonで通信するので、データ自体も構造体を丁寧に作成し、Marshal/Unmarshalなどの既存関数を活用できるような実装を意識した。

安定性

今回のプログラムは大学の内部にあるプログラムと通信するため、変なことをしてしまうと大学内部に攻撃されてしまう足掛かりになりかねなかった。そこでプログラム的に安全な書き方を常に意識した。

Go言語自体、危険なコードができにくいような体系ではあるが、go routineなどの資源のリークやバッファー周り、レースコンディションなど一般的に気を付けるべき点というのは一通り網羅した。

ログ

今回のプログラムは前提として、通信先すべてのプログラムがこのプログラムと通信することをデバッグする必要があったり、ほかにもどういう動作をしている人がいるのかといったことも、オンライン上で誰がどう弄っているかわからなかったりということもあった。

したがってログとしてほぼすべての情報を出力し、ログを見ることでどういう動作をしているかがよくわかるようにした。 またプログラムをGCP上に展開したので、他の人に運用を任せる際にGCPsshしてもらうのはうまくない。 そこでWeb側にもログの出力先を用意し、ポーリングでログが見られるようにAPIを用意した。

デバッグ時は非常に役立ったし、(容量/監視コストを無視するならば)ログは正義だなあと痛感した。 当日はGoogle Search Consoleとか同時操作人数とかの表示も併せて、リアルタイムでどこの県からどんな操作をしているのかが手に取るようにわかり、見ている分でも面白かったことも併記しておく。

コンフィグファイル

適当なコンソールのソフトを作った場合に、完成度として大きく評価される部分はコマンドライン引数まで丁寧に実装したかという点だと思う。今回のプログラムはほかの人が運用する際に設定としてまとめていた方が便利そうだったので、コンフィグファイルを用意した。

内容は以下のような感じ。

# デフォルトの設定
# 項目が指定されていなくてもハードコーティング済み

LogPath: "/tmp/iot_gateway.log"

HttpServer:
  Port: 8080
  BasicAuth:
    User: "someone"
    Password: "somepassword"
  StaticPath:
    # 静的ファイルのサーブ
    # フォルダ名をプレフィクスとしてファイルをサーブ
    - "/tmp" #この場合は http://server.address/tmp/file_in_the_dir.txt

SocketServer:
  Port: 8081

Strategy:
  # デバイスからの生存通知が途切れたと判断する時間
  DeviceCycle: 10 #sec
  # 制御権を与える時間
  ControlCycle: 90 #sec

上の設定は超危険な雰囲気でよろしくないが、yamlで書くだけでまっとうなソフトを書いている気がするので満足できる。 yamlを読むと単純に構造体として設定にアクセスできるので、非常に使い勝手がよい。

最後に

今までチームで開発する際には、時間がたっぷりある状態で計画を練りまくった状態で進めていたので、今回のような行き当たりばったりで実装する感じはやはりやりにくいなあとは感じてしまう。結局実装した機能のうち半分くらいは使えないまま終わってしまったり...

プログラム自体は適当に抽象化したので、どこかで機会があったら使えるといいなとも思う。


一緒に作成してくれた皆さんはお疲れ様でした。機会があればまたやりましょう。 当日見てくれた人はありがとうございました。

アドベントカレンダーはあともうちょっと続きます。 明日はRaspiのプログラムとか、全く想定していなかった実装で現れたESPのプログラムとこのAPIサーバーの中間プログラムをマッハで完成させてくれたgotti君の出番です。

OpenCVSharpで顔識別

OpenCVSharpシリーズもこれでネタ切れ。

Googleで"OpenCV 顔認識"なんで検索するとほとんどCascadeの記事で、顔の識別までやっているのは結構少なかったり、ましてやOpenCVSharpで触れられているものはないので、顔識別というのは結構マイナーなのかもしれない。そこで今回はCascade分類器で抽出した画像が、誰の顔なのか判断する方法について書く。

OpenCVの顔認識器は以下のアルゴリズムを使用する。

  • Eigenface
  • Fisherface
  • LBPH

使い方は全部FaceRecognizerのインターフェースを持っているので同じ。

faceRecognizer = FaceRecognizer.CreateEigenFaceRecognizer();
//faceRecognizer = FaceRecognizer.CreateFisherFaceRecognizer();
//faceRecognizer = FaceRecognizer.CreateLBPHFaceRecognizer();
faceRecognizer.Train(faces, label);
faceRecognizer.Predict(roi,out result, out confidence);

ただしEigenとFisherは入力する画像サイズがすべて一定で、Fisherについては分類対象が2つ以上ないと学習できないという制限がある。 というわけで実用上はLBPHを使えば何も問題なく使えると思う。

Predictに関しては信頼度を出してくれるので、それを基に分類すればだれの顔かよくわかる。

以上の流れをコードに書くとこんな感じ。ただしPoC的な感じなのでロジックと使う関数群は正しいがコピペでは動かず、適切に変数を宣言したり画像を整えたりなどをしないとうまく動作/認識できないかも。

//顔認識器の作成
//画像ファイルを開いて学習する
//画像ファイルはCascade分類器で検出した矩形(このコードでいうとroi)をImWriteすることで作成できる
//プログラム中でトレーニングするならそのまま配列で渡せばよい
List<Mat> faces = new List<Mat>();
List<int> label = new List<int>();
foreach(var f in paths)
{
    var img = Cv2.ImRead(f,LoadMode.GrayScale);
    if (img.Data == null) continue;
    //LPBHではサイズを一定にしなくても動く
    Cv2.Resize(img,img,new OpenCvSharp.CPlusPlus.Size(150, 150));
    faces.Add(img);
    //対象に応じてラベル付け
    //今回は1人だけ認識するので定数、増やす場合は適当に番号をつければOK
    label.Add(1);
}
faceRecognizer = FaceRecognizer.CreateLBPHFaceRecognizer();
faceRecognizer.Train(faces, label);

//顔認識の対象を抽出するために顔検出をするCascade分類器
var faceDetector = new CascadeClassifier("haarcascade_frontalface_default.xml");

while(true)
{
    Mat frame = new Mat();
    //カメラから読み込み
    source.Read(frame);
    //画像処理セクション
    var grayFrame = new Mat(0, 0, MatType.CV_8U);
    // グレースケールへ変換
    Cv2.CvtColor(frame, grayFrame, ColorConversion.RgbToGray);
    //顔の検出
    var faces = faceDetector.DetectMultiScale(grayFrame,
        minNeighbors: 50,
        minSize: new OpenCvSharp.CPlusPlus.Size(50,50), 
        maxSize: new OpenCvSharp.CPlusPlus.Size(500, 500));
    for(var i= 0; i<faces.Length; i++)
    {
        //対象エリアは顔の検出範囲
        Mat roi = new Mat(grayFrame, faces[i]);
        Cv2.Resize(roi, roi,new OpenCvSharp.CPlusPlus.Size(150, 150));
        int result;
        double confidence;
        //顔認識、resultに判別したラベルが入るが、今回は1人しか学習してないので無視
        faceRecognizer.Predict(roi,out result, out confidence);
        //一定以上の自信があれば表示
        if(confidence< 100)
        {
            //0に近いほうが信頼度が高いので、パーセントっぽく正規化
            confidence = 100 - confidence;
            //枠で囲って強調する
            frame.Rectangle(faces[i], new Scalar(0,0,255),2);
            frame.PutText(Math.Round(confidence,2).ToString()+"%", 
                faces[i].TopLeft, FontFace.HersheyPlain, 1.5, new Scalar(0, 0, 255));
        }
        else
        {
            //自信がなければ囲うだけ
            frame.Rectangle(faces[i], new Scalar(200, 0, 0),1);
        }
    }
    //表示, そのままだと大量にWindowが生成されるのでpictureboxなどに変えたほうがいい
    Cv2.ImShow("frame",frame);
}

フリーな同じ人の大量の顔データはなかなか落ちてないし、自分の顔を出すのもあれなので今回は動作例は省略。 動かしてみた感じだと学習済みのものと非常に似ていれば判定可能、写っている角度とか表情が未学習のモノだと壊滅的。 まあ学習も一瞬で終わるしこんなもんか。

OpenCVSharpで特徴点検出/マッチング

今回もOpenCVSharpについて。とりあえずもう触ることはないと思うのでアウトプットできることは覚えているうちにしてしまおう。

特徴点とは何かとか、特徴点検出のアルゴリズムの比較なんかは、いい記事がたくさんあったのでここではOpenCVSharpでどのアルゴリズムがどう使えるかを書こうと思う。

特徴点検出

使えるアルゴリズム

OpenCVでサポートされているアルゴリズムは以下の通りらしい。*1

  • cv::GFTTDetector
  • cv::AgastFeatureDetector
  • cv::FastFeatureDetector
  • cv::MSER
  • cv::BRISK
  • cv::KAZE
  • cv::ORB
  • cv::AKAZE
  • cv::xfeatures2d::StarDetector
  • cv::xfeatures2d::MSDDetector
  • cv::xfeatures2d::LATCH
  • cv::xfeatures2d::LUCID
  • cv::xfeatures2d::DAISY
  • cv::xfeatures2d::SIFT
  • cv::xfeatures2d::SURF

Source qiita.com

上の中でOpenCVSharp v.2.4.10 で定義を見つけられたアルゴリズムは以下の通り。 OpenCvSharp.CPlusPlusOpenCvSharp.CPlusPlus.Cv2名前空間以外に存在したらここではリストアップできてないけどこれだけあれば十分に思える。

  • OpenCvSharp.CPlusPlus.Cv2.GoodFeaturesToTrack
  • OpenCvSharp.CPlusPlus.FastFeatureDetector
  • OpenCvSharp.CPlusPlus.MSER
  • OpenCvSharp.CPlusPlus.BRISK
  • OpenCvSharp.CPlusPlus.ORB
  • OpenCvSharp.CPlusPlus.StarDetector
  • OpenCvSharp.CPlusPlus.SIFT
  • OpenCvSharp.CPlusPlus.SURF

使い方

上に上げたもの中でGoodFeatureToTrackだけメソッドとして提供されていて、ほかはFeatureDetectorのインターフェースを持っている。

GoodFeatureToTrack

var img = Cv2.ImRead("feature.png");
var gray = new Mat(img.Rows, img.Cols, MatType.CV_8UC1);
var mask = new Mat(img.Rows, img.Cols, MatType.CV_8UC1, new Scalar(255));
gray = img.CvtColor(ColorConversion.RgbToGray);
//検出、パラメータは適当
var corners = Cv2.GoodFeaturesToTrack(gray, 50, 0.01, 10, mask, 10, true, 5);
//KeyPointではないので地道に点打ち
foreach(var p in corners)
{
    Cv2.Circle(img, p, 5, new Scalar(255, 0, 0),-1);
}
Cv2.ImShow("img",img);

f:id:pfpfdev:20200716201004p:plain

それ以外

var img = Cv2.ImRead("feature.png");
var gray = new Mat(img.Rows, img.Cols, MatType.CV_8UC1);
gray = img.CvtColor(ColorConversion.RgbToGray);
//インターフェースが同じなのでどれでも同じようにつかえる
//var detector = new FastFeatureDetector();
//var detector = new MSER();
//var detector = new BRISK();
//var detector = new ORB();
//var detector = new StarDetector();
//var detector = new SIFT();
var detector = new SURF();
var kp = detector.Detect(gray);
Cv2.DrawKeypoints(gray, kp, img);
Cv2.ImShow("img",img);

f:id:pfpfdev:20200716201027p:plain

名前空間がちょっとだけ違うから見つけられない可能性はあるかもしれないけれど、 OpenCVのラッパーなので使い方自体はほかの言語と変わらないかなあ。

マッチング

こっちはOpenCVがサポートしているすべての方法が使える。

  • BruteForce
  • BruteForce-L1
  • BruteForce-Hamming
  • BruteForce-Hamming(2)
  • FlannBased

これをDescriptorMatcher.Create()にそのままstringとして渡せばよさそう。 もしくはBFMatcher,FlannBasedMatcherのコンストラクタをそのまま呼び出すか。

ここら辺もOpenCVの普通の使い方と同じ感じだから難しくないように思う。

var img = Cv2.ImRead("feature.png");
var gray = new Mat(img.Rows, img.Cols, MatType.CV_8UC1);
gray = img.CvtColor(ColorConversion.RgbToGray);
//さかさまにする回転行列
var rotateMatrix = Cv2.GetRotationMatrix2D(new Point2f(gray.Width / 2, gray.Height / 2), 180, 1);
//画像を回転
var rev = gray.WarpAffine(rotateMatrix, gray.Size());
//特徴点はSURF,ORBで検出する
//var detector = new ORB();
var detector = new SURF();
//正方向の画像について
var kpGray = detector.Detect(gray);
var dscGray = new Mat();
detector.Compute(gray, ref kpGray, dscGray);
//逆方向の画像について
var kpRev = detector.Detect(rev);
var dscRev = new Mat();
detector.Compute(rev, ref kpRev, dscRev);
//マッチング
//var matcher = DescriptorMatcher.Create("BruteForce-Hamming(2)");
//var matches = matcher.Match(dscGray, dscRev);
var matcher = new FlannBasedMatcher();
var matches = matcher.KnnMatch(dscGray, dscRev,1);
//出力
var output = new Mat();
Cv2.DrawMatches(gray, kpGray, rev, kpRev, matches, output);
Cv2.ImShow("output", output);

f:id:pfpfdev:20200716231814p:plain

ただし、Descriptorの内部形式がアルゴリズムによって違うので適切な組み合わせをしないといけない。 以下の記事で言及されているようにSIFT, SURFあたりはユークリッド距離を求めるFlannBasedと相性がよく、ORB, BRIEF, BRISK, FREAK, AKAZEはハフマン距離を求めるBF系と相性がいい。変換する方法もあるみたいだけどLshIndexParamとかはOpenCVSharpで見つけられなかったので、この組み合わせで使うようにすればよさげ。

stackoverflow.com

*1:detectインターフェースが実装されていないものは省略

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:じゃんけんの実装が適当すぎるけど

OpenCVのCascade分類器を複数のpos画像で学習する

今回もOpenCVの話。これはOpenCVSharpに限った話でもなく、PythonでもC++でも使えるのでまだ需要があるかもしれない。 普通Cascade分類器を使うときは学習済みのものを使うと思うが、大量の電気を使って自作してみたい人向けの情報。

OpenCVの標準のやり方ではopencv_createsamplesを用いて正解の画像を水増ししつつ、opencv_traincascadeで不正解の画像と合成して学習済みのxmlファイルを作成する。

今回紹介するのはこのopencv_createsamplesをちょっとだけ楽に使える方法。

opencv_createsamplesは一枚の画像もしくはinfoファイルと呼ばれるどのファイルのどこを処理するか記述したファイルから、角度や明るさを変更した画像を大量に生成してくれるユーティリティ。

詳しい使い方はmanを参照するとよくわかる。

manpages.ubuntu.com

一枚の画像を渡す分には普通にパスを渡すだけで十分なので簡単だが、複数のファイルを一気に処理しようとする際にはinfoファイルが必要になる。infoファイルは以下のようなフォーマットで、自動生成するのもちょっとめんどくさいようなものなのでこれをどうにかしたい。

./path/to/image1 x1 y1 width1 height1
./path/to/image2 x21 y21 width21 height21 x22 y22 width22 height22 ...
...
./path/to/imageN xN yN widthN heightN ...

これを自動生成するにはcascade分類器などが必要で、卵と鶏問題にもつながりそうな感じもするし、範囲の制限をしないで複数の画像を処理したい場合にはちょっと面倒な気がする。

そこでmergevecというユーティリティをつかって、opencv_createsamplesで一枚の画像から1つのvecファイルを作成し、複数のvecファイルを合成して1つのvecファイルを作成する。こうすると簡単にスクリプトなりバッチファイルを用意するだけで、複数の正解画像から水増ししたvecファイルを作れる。

github.com

使い方はvecファイルを一つのディレクトリにまとめて、python mergevec.py -v <vecファイルのあるdir> -o <出力先>

したがって次のようなファイル構成ではこんな感じでコマンドを実行すればよい。

├── mergevec.py
├── neg
│   ├── 1.png
│            ...
│   └── 100.png
└── pos
     ├── 1.png
             ...
     └── 10.png
# vecファイルの生成
seq 1 10|xargs -I @ opencv_createsamples -img pos/@.png -num 500 -vec pos/@.vec
# mergevecを使用
python mergevec.py mergevec -v pos -o pos.vec
# 出力dirを作成
mkdir output
# 不正解画像のリストを作成
find neg > neg.txt
# 学習
opencv_traincascade -data output -vec pos.vec -bg neg.txt -numPos 4500 -numNeg 100

こうやるだけで簡単に学習できた。

あとがき

確かにこの方法で学習はできるけど、上記のpos*4500,neg*100は30分くらいで処理が終わるが、レベルではまともに使えるレベルではなかった*1。ネットでは16時間回したとか色々出ているので、やっぱり個人レベルで学習しようとするには相当覚悟してファイルを集めて、PCを稼働させなければいけないのかもしれない。

*1:まあ圧倒的に不正解が少ないが

OpenCVSharpでCascade分類器

Deep Learningに頼らずに結構高速に顔の検出などができることで有名なCascade分類器のOpenCVSharpでの実装。 詳しい理論とか、実行例なんかはPythonで書いている記事がほかにもたくさんあると思うので、ここでは省略しようとおもう。

まずはインスタンスを作成。 ファイルはよく使われるここの学習データを用いた。

var faceDetector = new CascadeClassifier("haarcascade_frontalface_default.xml");

あとは検出したい画像をCV_8UC1のMatで用意してみて検出するだけ。

// グレースケールへ変換
Cv2.CvtColor(img,gray,ColorConversion.RgbToGray);
//顔の検出
//最大サイズと最小サイズを定義しておくと
//高速化&精度Upなので、できれば埋める
var faces = faceDetector.DetectMultiScale(gray,
    minSize: new OpenCvSharp.CPlusPlus.Size(150, 150), 
    maxSize: new OpenCvSharp.CPlusPlus.Size(250, 250));
foreach(var face in faces)
{
    //長方形で囲う
    Cv2.Rectangle(frame, face, new Scalar(0, 255, 0),3);
}

forで回しながらカメラから画像を取得し続ければ、動画で顔検出も可能。

Pythonで動画再生をするときはimshowで同じWindowタイトルを指定すれば勝手に更新してくれて簡単にできたけど、C#ではたくさんWindowが生成されてしまってそんな簡単にはいかない。さらに恐ろしいことにメモリリークを起こしてしまうとGCが働くまで1GBくらいまでメモリを食ってしまうこともあるので、ちゃんとメモリ対策も行うとこんな感じで実装できそう。

OpenCVSharp v2.4.10なんて古いバージョンを使っているのと同じように、GUIWPFではなくFormアプリケーションでやらないといけなかった...orz。

public partial class Form1 : Form
{
    public Form1()
    {
        InitializeComponent();
    }

    VideoCapture source;
    CascadeClassifier faceDetector;
    System.Threading.Timer timer;
    private void Form1_Load(object sender, EventArgs e)
    {
        //カメラ読み込み
        source = new VideoCapture(0);
        //カスケード分類器の読み込み
        faceDetector = new CascadeClassifier("haarcascade_frontalface_default.xml");
        //更新頻度
        int interval = (int)(1000 / source.Fps);
        //定期実行
        timer = new System.Threading.Timer(update, null, 0, interval);
    }

    private void update(object state)
    {
        using (Mat img = new Mat())
        {
            source.Read(img);
            if (img == null) return;
            Cv2.Resize(img,img,new OpenCvSharp.CPlusPlus.Size(pictureBox1.Width, pictureBox1.Height));
            using (Mat gray = new Mat())
            {
                // グレースケールへ変換
                Cv2.CvtColor(img, gray, ColorConversion.RgbToGray);
                //顔の検出
                //最大サイズと最小サイズを定義しておくと
                //高速化&精度Upなので、できれば埋める
                var faces = faceDetector.DetectMultiScale(gray,
                    minSize: new OpenCvSharp.CPlusPlus.Size(150, 150),
                    maxSize: new OpenCvSharp.CPlusPlus.Size(250, 250));
                foreach (var face in faces)
                {
                    //長方形で囲う
                    Cv2.Rectangle(img, face, new Scalar(0, 255, 0), 2);
                }
            }
            pictureBox1_Update(img);
        }
    }
    //メモリリークを防ぎながらpictureBox1の内容更新を行う
    private void pictureBox1_Update(Mat mat)
    {
        //開放のためにアクセスできるようにしておく
        var oldImg = pictureBox1.Image;
        //更新してからでないと例外発生
        pictureBox1.Image = BitmapConverter.ToBitmap(mat);
        //ここで開放しないと見事にメモリリーク
        if (oldImg != null)
        {
            oldImg.Dispose();
        }
    }
}

f:id:pfpfdev:20200715163957p:plain
レナさんを検出する様子

参考

メモリ対策 atsukanrock.hatenablog.com

次は自作の学習ファイルを作成する話かな。

CYC1000のピン配置ともろもろ

簡単なレビュー

今回はこのFPGAボードに対するレビューをしたいと思っていたけれど、初めてのFPGAボードだし"動いた!"以上の感想もなければ他と比較できるほどの知識も持っていないので、サイズ感などさしあたり問題ない部分だけレビューしてみる。

この木々のたどり着くような人なら持っているであろうRaspberryPI ZeroWとのサイズ比較はこんな感じ。

f:id:pfpfdev:20200406120059j:plain
RasPi Zero vs CYC1000

ちょうどRasPiよりも一回り小さいくらいで、USB Blaster付きのFPGAボードとしては最小クラスなのかなと。*1

このボードには次のような部品が載っているらしく、予め書き込まれていたプログラムではだいたいの動作が確認できた。

  • 8MB SDRAM 166MHz -> Referenceデザインにはピンが定義されているから使われてそうだけど外からはわからない
  • 3軸ジャイロセンサー -> 傾けるとLEDがそれに応じて光るモードがあった
  • 8個の赤色LED -> 何パターンか光る
  • プッシュボタン -> LEDの点灯パターン切り替え

予め書き込まれていたプログラムではNiosIIを使っているらしいので、CPUも実装できることも保証されていそう。 とりあえず前回書いたような、FPGAボードに求めることはすべてできそうなので問題はなさそう。

いかんせん情報源が少ないので、公式のWikiを読むかリファレンスデザインを読むかしないと行けないけれど、FPGA初心者にはわからん用語もあったり、そもそもQuartus自体も全機能把握できていないのでいろいろと試行錯誤しながら開発しないといけなさそう。

ピン配置

ArduinoやRaspberryPiだとピンの配置がよくわかる画像があるけれど、CYC1000はピンアウトとの対応が乗っているやつとかなくて、不便に思ったのでピンの配置だけ抜粋してみる。

LEDとボタン、GPIOピンは以下の写真参照。

f:id:pfpfdev:20200407192121p:plain
CYC1000 Pinout

SDRAMやUARTもリファレンスデザインでは次のようにマッピングされている。 ココらへんはまだ触ってないのであとから検証した記事を出す予定。

Pin name Mode Address(ex. Bank 1,Pin A1 ->B1_A1)
sdram_addr[11] Output B8_N0
sdram_addr[10] Output B8_N0
sdram_addr[9] Output B8_N0
sdram_addr[8] Output B8_N0
sdram_addr[7] Output B8_N0
sdram_addr[6] Output B8_N0
sdram_addr[5] Output B8_N0
sdram_addr[4] Output B8_N0
sdram_addr[3] Output B8_N0
sdram_addr[2] Output B8_N0
sdram_addr[1] Output B8_N0
sdram_addr[0] Output B8_N0
sdram_ba[1] Output B8_N0
sdram_ba[0] Output B8_N0
sdram_casn Output B8_N0
sdram_cke Output B8_N0
sdram_clk Output B7_N0
sdram_csn Output B8_N0
sdram_dq[15] Bidir B7_N0
sdram_dq[14] Bidir B7_N0
sdram_dq[13] Bidir B7_N0
sdram_dq[12] Bidir B7_N0
sdram_dq[11] Bidir B7_N0
sdram_dq[10] Bidir B7_N0
sdram_dq[9] Bidir B7_N0
sdram_dq[8] Bidir B7_N0
sdram_dq[7] Bidir B7_N0
sdram_dq[6] Bidir B7_N0
sdram_dq[5] Bidir B7_N0
sdram_dq[4] Bidir B7_N0
sdram_dq[3] Bidir B7_N0
sdram_dq[2] Bidir B7_N0
sdram_dq[1] Bidir B7_N0
sdram_dq[0] Bidir B7_N0
sdram_dqm[1] Output B7_N0
sdram_dqm[0] Output B7_N0
sdram_rasn Output B8_N0
sdram_wen Output B8_N0
spi_g_sen_clk Output B1_N0
spi_g_sen_csn Output B1_N0
spi_g_sen_miso Input B1_N0
spi_g_sen_mosi Output B1_N0
uart_rxd Output B3_N0
uart_txd Input B3_N0

ここまでの情報は、以下のソースを基に作ったけど、誤植の責任は負いません。

ネットで情報を得るときは"CYC1000"よりも"TEI0003-02"という型番のほうがおいしい情報にアクセスできる気がする。

参考

wiki.trenz-electronic.de

*1:選定時に見た他のFPGAボードの印象