post Image
二次元画像を「映え」させる【GrabCut】

はじめに

最近流行っているじゃないですか、iPhoneXやPixel 3で人やものを撮影すると背景がぼけるこれ、
ポートレートモード。
SS

モデル: 同じチームのozpaさん
iPhoneXS cameras

これをですね、深度情報もない顔情報も上手く取れない、アニメ、ゲーム、マンガなどの二次元画像でもやりたい。
二次元画像を「映え」させたい

ということで、GrabCutというアルゴリズムを用いて前景/後景を対話的に分離し、
背景をぼかして「映え」させる、iOSアプリを作りました。

結果は、

これが
SS-before
こう
SS-after

これが
SS-before
こう
SS-after

こいつらはこんな手順で
SS-after

簡単に✨映える

アルゴリズムと実装の説明をします。

GrabCut

GrabCutはこちらの論文で提案された、なるべく少ないユーザ入力から画像中の前景領域を抽出するアルゴリズムです。
Gaussian Mixture Model(GMM)というクラスタリング手法を用いて、画素が前景である確率、後景である確率を求めます。

クラスタリングのイメージ

まず、画像を下記のような隣接する画素同士を連結したグラフとして扱います。
SS-after

次に、例えば、類似した2つの画素は同一の前景または後景である可能性が高く、分離して欲しくないので、2点間の辺に重めのコストをかけます。
全ての辺のコストを計算し、最もコストがかからない、できるだけ滑らかな境界の切断を繰り返し求める、最小化問題を解く雰囲気で領域を分割します。

AとBの境界の切断例
SS-after

ユーザによる入力

面白いのは、結果をよくするためにユーザによる入力を対話的に利用すること。
ユーザは初期値として、前景領域の周りに長方形を描きます。初期値を元に上記のクラスタリングを適応すると、たまに、そうじゃないんだよおおお、おしいいいってことがあります。そうじゃない場所に、「ここは後景ではなく前景だよ」といった線を引くと、それを元にコストを再計算。最終的に綺麗に分割できます。

入力← → 出力
SS-after
長方形と白ペン黒ペンで前景/後景情報を入力している
引用: OpenCV-Pythonチュートリアル

実装

今回はアルゴリズムの実装をOpenCV、インターフェースの実装をみなさんにお任せして、
OpenCVでどう映えアプリの機能を実装するかをコメント多めで載せまする。

アプリ作成手順

  1. OpenCVを使えるようにする
  2. GrabCutする🔪
  3. 背景をぼかす📷
  4. 合成する

1. OpenCVを使えるようにする

  1. 公式からframeworkをダウンロード
  2. プロジェクトにドラッグ&ドロップ
  3. コードを書くファイルを用意

[File] > [New] > [Flie] > [Cocoa Touch Class] の Language: Objective-Cを追加
ここでは「OpenCVManager.h / OpenCVManager.m」と命名

Create Bridging Header
OpenCVManager.m > OpenCVManager.mm に変更
4. OpenCVのimport

OpenCVManager.mm
//最初にOpenCVのヘッダーをimportすること
#import <opencv2/opencv.hpp>
#import <opencv2/imgcodecs/ios.h>
#import "OpenCVManager.h"

2. GrabCutする

はじめに、ユーザが入力した長方形から、前景らしい領域を白塗りしたマスク画像を生成します。

OpenCVManager.mm
-(UIImage*)doGrabCut:(UIImage*)sourceImage foregroundRect:(CGRect)rect iterationCount:(int)iterationCount {
    //UIImageをMatに変換
    cv::Mat image;
    UIImageToMat(sourceImage, image);
    //RGBA > RGB
    cv::cvtColor(image, image, CV_RGBA2RGB);
    //CGRectをRectに変換
    cv::Rect rectangle(rect.origin.x, rect.origin.y, rect.size.width, rect.size.height);

    //GrabCut
    //mask: 結果
    //bgModel, fgMolde: 処理で使用する配列
    //iterationCount: 処理を繰り返す回数
    cv::grabCut(image, mask, rectangle, bgModel, fgModel, iterationCount, cv::GC_INIT_WITH_RECT);

    //結果から、前景らしい領域(GC_PR_FGD)を抽出
    cv::Mat1b fgMask;
    cv::compare(mask, cv::GC_PR_FGD, fgMask, cv::CMP_EQ);
    return MatToUIImage(fgMask);
}

次に👆の結果をさらによくするために、前景領域、後景領域の指定をユーザから雑に入力されたマスク画像を用いて、再計算を繰り返します。

OpenCVManager.mm
-(UIImage*)doGrabCut:(UIImage*)sourceImage maskImage:(UIImage*)maskImage iterationCount:(int)iterationCount {
    cv::Mat image;
    UIImageToMat(sourceImage, image);
    cv::cvtColor(image, image, CV_RGBA2RGB);

    //ユーザ入力されたマスク画像を既存の結果と合成する
    cv::Mat maskImg;
    UIImageToMat(maskImage, maskImg);
    cv::Mat1b markers = [self cvMatMaskFromUIImage:maskImage];

    //GrabCut
    cv::grabCut(image, markers, cv::Rect(), bgModel, fgModel, iterationCount, cv::GC_INIT_WITH_MASK);

    //GC_FGDをGC_PR_FGDに変換
    cv::MatIterator_<unsigned char> itd = markers.begin();
    cv::MatIterator_<unsigned char> itd_end = markers.end();
    for(int i=0; itd != itd_end; ++itd, ++i) {
        if (*itd == cv::GC_FGD) {
            *itd = cv::GC_PR_FGD;
        }
    }

    cv::Mat1b fgMask;
    cv::compare(markers, cv::GC_PR_FGD, fgMask, cv::CMP_EQ);
    return MatToUIImage(fgMask);
}

ユーザから入力されたマスク画像を既存の結果と合成するコードはこちら

OpenCVManager.mm
-(cv::Mat1b)cvMatMaskFromUIImage:(UIImage*)image {
    //マスク画像の画素を抽出
    CGImageRef imageRef = [image CGImage];
    NSUInteger width = CGImageGetWidth(imageRef);
    NSUInteger height = CGImageGetHeight(imageRef);
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
    unsigned char *rawData = (unsigned char*) calloc(height * width * 4, sizeof(unsigned char));
    NSUInteger bytesPerPixel = 4;
    NSUInteger bytesPerRow = bytesPerPixel * width;
    NSUInteger bitsPerComponent = 8;
    CGContextRef context = CGBitmapContextCreate(rawData, width, height, bitsPerComponent, bytesPerRow, colorSpace, kCGImageAlphaPremultipliedLast | kCGBitmapByteOrder32Big);
    CGColorSpaceRelease(colorSpace);
    CGContextDrawImage(context, CGRectMake(0, 0, width, height), imageRef);
    CGContextRelease(context);

    //既存の結果と合成
    cv::Mat1b markers = mask;
    uchar* data =  markers.data;
    for(int x = 0; x < width; x++) {
        for( int y = 0; y < height; y++) {
            NSUInteger byteIndex = ((image.size.width  * y) + x ) * 4;
            UInt8 red   = rawData[byteIndex];
            UInt8 green = rawData[byteIndex + 1];
            UInt8 blue  = rawData[byteIndex + 2];
            UInt8 alpha = rawData[byteIndex + 3];
            if(red == 0 && green == 0 && blue == 255 && alpha == 255) {//青領域を前景
                data[width * y + x] = cv::GC_FGD;
            } else if(red == 255 && green == 0 && blue == 0 && alpha == 255) {//赤領域を後景
                data[width * y + x] = cv::GC_BGD;
            }
        }
    }
    free(rawData);
    return markers;
}

3. 背景をぼかす

「映え」るぼかしをしましょう。ガウシアン推し。

OpenCVManager.mm
//image: 入力
//blurImage: 出力
//sigumaX: XYの標準偏差、X=Y=0の時blurSizeから決定する
double sigumaX = 0;
cv::GaussianBlur(image, blurImage, cv::Size(blurSize, blurSize), sigumaX);

(境界のぼかしを良い感じにする手法は別で紹介予定)

4. 合成する

生成した、前景マスク、ぼかし画像、元画像を合成しましょう。

OpenCVManager.mm
//前景マスクから後景マスク生成
bitwise_not(fgMask, bgMask);

//合成
cv::Mat bgImg, fgImg, result;
cv::bitwise_and(blurImage, blurImage, bgImg, bgMask);
cv::bitwise_and(image, image, fgImg, fgMask);
cv::add(bgImg, fgImg, result);
return MatToUIImage(result);

あとはこやつらをSwiftから呼び出して処理すれば完成🎉
SS
SS

おわりに

自分が欲しいものを作るの楽しいいい
本当に映えさせたい3Dゲームのスクリーンショットや二次元美少女画像の結果は@koooootakeにあげるのでみてね!

前景/後景分離は、深度、顔、の他にもディープなラーニングなど様々なアプローチがありそうなので実装してみたい。話題のremoveとか、素晴らしい。

宣伝

DeNA Tech Con、2月6日、きてね!
神武が所属するマンガボックス、サーバー開発エンジニア急募、きてね!


『 Swift 』Article List