cloverrose's blog

Python, Machine learning, Emacs, CI/CD, Webアプリなど

ゆゆ式 Advent Calendar 2016 17日目 AR縁ちゃんを作りました

f:id:cloverrose:20161218000239p:plain

ゆゆ式アドベントカレンダー2016の17日目です。

www.adventar.org

今年自分はARで縁ちゃんを発見するアプリ、「AR縁ちゃん」を作りました。

AR縁ちゃんとはカメラの画像をリアルタイムで解析して、縁ちゃんの目っぽいもの(コンセント)があったら、そこに縁ちゃんを描画するアプリです。

動画を見ていただくとわかりやすいかと思います。

youtu.be

実装中のコードはGithubにあげてあります。まだ改善したい箇所があるのでぼちぼち更新します。

github.com

コードが完成したら版権を考慮してなんらかの形で公開したいと思います。

昨日は

hayashida_2ndさんのゆずこたち三人と猫ちゃんのかわいいイラストでした。

偶然だけど僕の飼ってる猫と毛色が同じだった!

今年もかわいいイラストや面白いゲーム、考察、まとめなど0:00が楽しみな毎日でした。

自分も0:00に投稿したかったのですがギリギリになってしまいました m(_)m

かわいい縁ちゃんのコンセント目について

gifmagazine.net

コンセント縁ちゃんにはこんなかわいいイラストがあります。

ゆかりちゃんの目がコンセントっぽいところを集めた動画もあります。

www.nicovideo.jp

余談

Androidのカメラ画像の四角形を認識したい!と同期のエンジニアに相談したらOpenCVでできそうだよ、と下記のサイトを紹介してもらったのがスタートになります。

OpenCV shape detection - PyImageSearch

具体的にはコンセントを認識したいと話したら縁ちゃんの目だなと察した友達がいました。

以下は技術メモになります。OpenCVJavaで使うサンプルがあまりないので他の人の役に立つと幸いです。

技術メモ

前回OpenCVでface-detectionの例題を試しました。

cloverrose.hateblo.jp

今回はその例題をもとに自分のやりたい画像処理にコードを変えていきます。

手順0. 変更する箇所の把握と画像処理のための仕組みの理解

OpenCVでの雛形とか画像処理の呼ばれるタイミングなどはOpenCV for Android入門 – カメラ編 « Rest Termが丁寧でわかりやすいです。

AndroidOpenCVによる画像処理をする方法と基礎的な知識がわかりました。

public Mat onCameraFrame(CvCameraViewFrame inputFrame)メソッドが繰り返し呼ばれるメソッドでこの中で画像処理を行います。

次のステップから実際にコンセント(縁ちゃんの目)を認識、描画するコードを書いていきます。

その前にOpenCVの参考になるサイトを貼っておきます

手順1. 境界検出の準備

手順1.0. 入力データの取得

mRgba = inputFrame.rgba();
mGray = inputFrame.gray();

手順1.1. 画像の平滑化

Does the canny method apply Gaussian Blur? - OpenCV Q&A Forum によると以下のことがわかりました。

  • Cannyフィルタ内部では画像の平滑化は行っていない
  • Cannyフィルタを呼ぶ前に平滑化かけたほうがいい
  • エッジを残して平滑化するにはbilateralFilterがいい

bilateralFilterについて(引用元 バイラテラルフィルタ | イメージングソリューション

ガウシアンフィルタなどのフィルタでは、ノイズをできるだけ除去しようとすると、輪郭もボケてしまうという欠点がありました。 この欠点を解決しようとした処理アルゴリズムバイラテラルフィルタ(bilateral filter)です。

良さそうです。

ただ処理が結構重い(dやsigmaSpaceを大きくすると顕著)+Grayスケール画像で平滑化の効果があまりわからないので、一旦コメントアウトかな。

// Mat bilateralImage = new Mat();
// private int d = 3;
// private double sigmaColor = 1.0;
// private double sigmaSpace = 1.0;
// Imgproc.bilateralFilter(mGray, bilateralImage, d, sigmaColor, sigmaSpace);

ここでは定番っぽいGaussianBlurを使うことにします。

Mat blurImage = new Mat();
Imgproc.GaussianBlur(mGray, blurImage, new Size(7, 7), 0, 0);

手順 1.2. Cannyフィルタ

Cannyフィルタで境界線をくっきりさせます。

参考 OpenCV for Android入門 – カメラ編 « Rest Term

Cannyフィルタのパラメタによってエッジの検出結果がかなり変わります。

屋外・屋内・風景・ディスプレイと撮影画像によって最適なパラメタは違うので、Otsu's methodを使って自動で調整します。

Otsu's method 参考

Mat thresholdImage = new Mat();
Mat cannyImage = new Mat();
// しきい値を自動計算 http://stackoverflow.com/a/21326830:title
double highThreshold = Imgproc.threshold(blurImage, thresholdImage, 0, 255, Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU);
double lowThreshold = 0.5 * highThreshold;
Imgproc.Canny(thresholdImage, cannyImage, lowThreshold, highThreshold);
Log.d("Canny", "threshold low, high = " + lowThreshold + ", " + highThreshold);

ディスプレイのコンセントのイラスト low, high = 50, 100程度 f:id:cloverrose:20161210212452p:plain

壁のコンセント low, hight = 55,110程度

f:id:cloverrose:20161210214225p:plain

おまけ絨毯の上の猫(横向き画像)low, high = 55, 110

f:id:cloverrose:20161210212525p:plain

良さそう。周辺部のノイズは矩形のサイズの条件で除去できる。

手順2. 境界検出

参考 Object Detection — OpenCV Java Tutorials 1.0 documentation

これはJavaFXでのアプリで、関係ない部分も多いですがImgproc.findContoursの使い方の参考になりました。

ただし、findContoursはGrayスケールの2値画像を入力しないといけない点に注意です。

RGBAの4チャネルの画像を渡すとこんな感じのエラーが出てきます。

android - OpenCV Error: Unsupported format or combination of formats - Stack Overflow

SOの回答にもある通りグレースケールの画像を使いましょう。

object-detection/ObjRecognitionController.java at master · opencv-java/object-detection · GitHub

ブログではコードも公開してくれています。これが関係するところです。

List<MatOfPoint> contours = new ArrayList<>();
Mat hierarchy = new Mat();

// find contours
Imgproc.findContours(cannyImage, contours, hierarchy, Imgproc.RETR_CCOMP, Imgproc.CHAIN_APPROX_SIMPLE);

手順3. 境界を囲む矩形を取り出す

境界線は複雑な形なので、それを囲う四角形として扱います。

参考 OpenCVで遊ぼう!: OpenCV-javaで輪郭検出

この記事を読むまで四角形にすることが思いつかなくて苦戦してました。

List<RotatedRect> rrList = new ArrayList<RotatedRect>();

// if any contour exist...
if (hierarchy.size().height > 0 && hierarchy.size().width > 0) {
    // for each contour, display it in blue
    for (int idx = 0; idx >= 0; idx = (int) hierarchy.get(0, idx)[0]) {
        MatOfPoint pmat = contours.get(idx);
        MatOfPoint2f ptmat2 = new MatOfPoint2f(pmat.toArray()); // API的にFloat型に変換する
        RotatedRect bbox = Imgproc.minAreaRect(ptmat2); // 回転を考慮した外接矩形

        if (!isEye(bbox.size.width, bbox.size.height)) {
            continue;
        }
        rrList.add(bbox);
    }
}

手順4. コンセントの穴(縁ちゃんの目)らしいか判定(片目ずつ)

サイズの数字は実験で決めました。

コンセントの穴の大きさが右と左でちがうのはなぜ?-電気のふしぎ-なんでも科学百科-サイエンスワールド-キッズ・ミュージアム-四国電力- にあるように、コンセントは左右で長さが違います。

実際にコンセントの縦横のアスペクト比を測りました。

  • 長い方 23:6くらい
  • 短い方 17:6くらい

アスペクト比がこれくらいの四角形だけを目と認識します。

private static final double longAspect = 23/6;
private static final double shortAspect = 17/6;
private static final double minWidth = 10;
private static final double minHeight = shortAspect * minWidth;
private static final double maxWidth = 40;
private static final double maxHeight = longAspect * maxWidth;

private boolean isEye(double width, double height) {
    double areaMinThreshold = minWidth * minHeight;
    double areaMaxThreshold = maxWidth * maxHeight;
    double aspectMinThreshold = aspect * 0.7;
    double aspectMaxThreshold = aspect / 0.7;

    double area = width * height;
    if (area < areaMinThreshold || area > areaMaxThreshold) {
        return false;
    }
    double aspect = Math.max(width, height) / Math.min(width, height);
    if (aspect < aspectMinThreshold || aspect > aspectMaxThreshold) {
        return false;
    }
    return true;
}

手順5. コンセントの穴(縁ちゃんの目)らしいか判定(両目)

ここではペアにしたときに、大きさ、角度、距離などからそれが右目・左目っぽいかを判定します。

int len = rrList.size();
for (int i = 0; i< len; i ++) {
    RotatedRect rr1 = rrList.get(i);
    for (int j =0; j<len; j++){
        if (j == i){
            continue;
        }
        RotatedRect rr2 = rrList.get(j);
        if (isEyes(rr1, rr2)) {
            drawEye(rr1, frame);
            drawEye(rr2, frame);
        }
    }
}
private boolean isEyes(RotatedRect rr1, RotatedRect rr2) {
    // 大きさがだいたい同じ
    double w1 = rr1.size.width;
    double h1 = rr1.size.height;
    double w2 = rr2.size.width;
    double h2 = rr2.size.height;
    if (Math.max(w1, w2) / Math.min(w1, w2) > 1.5 || Math.max(h1, h2) / Math.min(h1, h2) > 1.5) {
        return false;
    }

    // 角度がだいたい同じ
    double a1 = rr1.angle;
    double a2 = rr2.angle;
    if (Math.abs(a1 - a2) > 10) {
        return false;
    }

    // 距離がいい感じ
    double cx1;
    double cy1;
    {
        Point points[] = new Point[4];
        rr1.points(points);
        double sumX = 0.0;
        double sumY = 0.0;
        for (Point p : points) {
            sumX += p.x;
            sumY += p.y;
        }
        cx1 = sumX / 4.0;
        cy1 = sumY / 4.0;
    }
    double cx2;
    double cy2;
    {
        Point points[] = new Point[4];
        rr2.points(points);
        double sumX = 0.0;
        double sumY = 0.0;
        for (Point p : points) {
            sumX += p.x;
            sumY += p.y;
        }
        cx2 = sumX / 4.0;
        cy2 = sumY / 4.0;
    }

    // 左右の目の重心は、目の横幅の5倍くらい離れているのが理想
    double distance = Math.sqrt(Math.pow(cx1 - cx2, 2) + Math.pow(cy1 - cy2, 2));
    double expected = Math.min(w1, h1) * 5;
    if (distance < expected * 0.8 || distance > expected / 0.8) {
        return false;
    }

    return true;
}

手順6. 目の枠を描画

参考 android - opencv how to draw minAreaRect in java - Stack Overflow

RotatedRectを描画するAPIはなかった。

そこで4点の座標を取得し、要素数1のcontoursを作成し、描画する。

Imgproc.lineメソッドで描画してもいいけど、順番とか始点終点が一致とかを気にするのが嫌なのでこちらを使った。

private static final Scalar eyeColor = new Scalar(255, 0, 0);

private Mat drawEye(RotatedRect bbox, Mat frame) {
    Point points[] = new Point[4];
    bbox.points(points);
    MatOfPoint mop = new MatOfPoint(points);
    List<MatOfPoint> contours = new ArrayList<>();
    contours.add(mop);
    Imgproc.drawContours(frame, contours, 0, eyeColor, 2);
    return frame;
}

手順 7. 動作確認

[フリーイラスト] コンセント - GATAG|フリー素材集 壱 このコンセント画像を使わせてもらいました。

f:id:cloverrose:20161210174504p:plain

やったね!

緑の四角形はデバッグ用のもので、認識するべき目の最大と最小を描画しています。

縁ちゃんを描画する

画像の読み込み(Mat形式)

参考 java - Loading an image using OpenCV in Android - Stack Overflow

private Mat yukari;

private void loadYukari() {
    Bitmap bmp = BitmapFactory.decodeResource(getResources(), R.drawable.yukari_line);
    yukari = new Mat();
    Utils.bitmapToMat(bmp, yukari);
}
画像の重ね合わせ

透明な前景画像をアフィン変換を使って背景画像に重ねる。

アフィン変換についてはここが詳しい OpenCVで画像上に別の画像を描画する - Qiita (ただしC++

アフィン変換についてJava のちょうどのコードがなかなか見つからなかった。Githubで見つかった。 ARCameraTest/IPface.java at 20aa0f536a0466ed0c57790726d704a5d2be0848 · take-iwiw/ARCameraTest · GitHub

画像を透明なまま重ねる方法についてはここが詳しかった OpenCVでアルファチャンネル付きpngを表示する - Qiita (ただしC++

OpenCVのバージョンが古いか何かでAPIが違う。けど画像でイメージしやすかった記事

とりあえず左上に縁ちゃんを表示するコードはこんな感じ

Point [] srcTri = new Point[]{
        new Point(0.0f, 0.0f),
        new Point(yukari.width(), 0.0f),
        new Point(yukari.width(), yukari.height()),
};
Point [] dstTri = new Point[] {
        new Point(0, 0),
        new Point(100, 0),
        new Point(100, 100),
};

Mat affineTrans = Imgproc.getAffineTransform(new MatOfPoint2f(srcTri), new MatOfPoint2f(dstTri));
Imgproc.warpAffine(yukari, frame, affineTrans, frame.size(), Imgproc.INTER_LINEAR, Core.BORDER_TRANSPARENT, new Scalar(0, 0, 0));

結構透明なまま重ねるのは難しかった

ハマったところとしてAndroidOpenCVの背景画像がRGBAの4チャネルなのをAチャネルを削除してRGBの3チャネルにする必要があった。

実は時間が足りなくてまだデバイスの向きと、コンセントの傾きにちゃんと対応していない。以下のコードはデバイスを横向きにして、コンセントが通常の||のときだけちゃんと動く。

時間を見つけて改善していきます。

/**
 *  RGBA画像からAlphaを削除してRGB画像にする
 * @param img_rgba
 * @return
 */
private Mat removeAlpha(Mat img_rgba) {
    List<Mat> planes_rgba = new ArrayList<>();
    for (int i = 0; i< 4; i++){
        planes_rgba.add(new Mat());
    }
    Core.split(img_rgba, planes_rgba);

    List<Mat> planes_rgb = new ArrayList<>();
    planes_rgb.add(planes_rgba.get(0));
    planes_rgb.add(planes_rgba.get(1));
    planes_rgb.add(planes_rgba.get(2));
    Mat img_rgb = new Mat();
    Core.merge(planes_rgb, img_rgb);
    return img_rgb;
}

/***
 *
 * @param backgroundImage
 * @param foregroundImage
 * @return
 */
private Mat overlayImage(Mat backgroundImage, Mat foregroundImage) {
    assert(backgroundImage.channels() == foregroundImage.channels());
    assert(backgroundImage.size() == foregroundImage.size());

    int maxVal = 255; //(int)(Math.pow(2, 8 * backgroundImage.elemSize1()) - 1);

    // チャンネルに分解
    List<Mat> planes_rgba = new ArrayList<>();
    Core.split(foregroundImage, planes_rgba);

    //RGBA画像をRGBに変換
    Mat img_rgb = new Mat();
    Core.merge(Arrays.asList(planes_rgba.get(0), planes_rgba.get(1), planes_rgba.get(2)), img_rgb);

    //RGBA画像からアルファチャンネル抽出
    Mat img_aaa = new Mat();
    Core.merge(Arrays.asList(planes_rgba.get(3), planes_rgba.get(3), planes_rgba.get(3)), img_aaa);

    //背景用アルファチャンネル
    Mat negpos = new Mat();
    Core.bitwise_not(planes_rgba.get(3), negpos);
    Mat img_1ma = new Mat();
    Core.merge(Arrays.asList(negpos, negpos, negpos), img_1ma);


    Mat img_dst1 = new Mat();
    Mat img_dst2 = new Mat();
    Mat img_dst3 = new Mat();
    Core.multiply(img_rgb, img_aaa, img_dst1, 1.0 / maxVal);
    Core.multiply(backgroundImage, img_1ma, img_dst2, 1.0 / maxVal);
    Core.add(img_dst1, img_dst2, img_dst3);
    return img_dst3;
}

/**
 *  縁ちゃん画像の目の位置を合わせるためのアフィン変換行列を計算
 * @param foregroundImage
 * @param rr1
 * @param rr2
 * @return
 */
private Mat computeAffineTransform(Mat foregroundImage, RotatedRect rr1, RotatedRect rr2) {
    Point [] srcTri = new Point[]{
            new Point(0.0f, 0.0f),
            new Point(foregroundImage.width(), 0.0f),
            new Point(foregroundImage.width(), foregroundImage.height()),
    };

    Point points1[] = new Point[4];
    rr1.points(points1);

    Point points2[] = new Point[4];
    rr2.points(points2);

    Point tl = points1[0];
    Point br = points1[0];
    double tld = Math.sqrt(Math.pow(tl.x, 2) + Math.pow(tl.y, 2));
    double brd = Math.sqrt(Math.pow(br.x, 2) + Math.pow(br.y, 2));
    for (Point point : points1) {
        double d = Math.sqrt(Math.pow(point.x, 2) + Math.pow(point.y, 2));
        if (d <= tld){
            tl = point;
            tld = d;
        }
        if (d >= brd) {
            br = point;
            brd = d;
        }
    }
    for (Point point : points2) {
        double d = Math.sqrt(Math.pow(point.x, 2) + Math.pow(point.y, 2));
        if (d <= tld){
            tl = point;
            tld = d;
        }
        if (d >= brd) {
            br = point;
            brd = d;
        }
    }
    double width = br.x - tl.x;
    double height = br.y - tl.y;

    Point [] dstTri = new Point[] {
            new Point(tl.x - 1.4 * width, tl.y - 2.3 * height),
            new Point(br.x + 1.6 * width, tl.y - 2.3 * height),
            new Point(br.x + 1.6 * width, br.y + 3.4 * height),
    };

    // 変形行列を作成
    Mat affineTrans = Imgproc.getAffineTransform(new MatOfPoint2f(srcTri), new MatOfPoint2f(dstTri));

    return affineTrans;
}

/**
 * 縁ちゃんを描く
 * @param frame
 * @return
 */
private Mat drawYukari(Mat frame) {
    Mat backgroundImage = removeAlpha(frame);

    for (List<RotatedRect> eyePair : eyes) {
        RotatedRect rr1 = eyePair.get(0);
        RotatedRect rr2 = eyePair.get(1);

        Mat affineTrans = computeAffineTransform(yukari, rr1, rr2);

        // 出力画像と同じ幅・高さのアルファ付き画像を作成
        Mat alpha0 = new Mat(frame.rows(), frame.cols(), yukari.type());
        Imgproc.warpAffine(yukari, alpha0, affineTrans, alpha0.size(), Imgproc.INTER_LINEAR, Core.BORDER_TRANSPARENT, new Scalar(0, 0, 0));

        backgroundImage = overlayImage(backgroundImage, alpha0);
        return backgroundImage;
    }
    return backgroundImage;
}