OpenCVで写真を漫画風に加工しよう 〜実装編〜

iOS
174件のシェア(すこし話題の記事)

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

前回のおさらい

前回は漫画カメラで撮影した写真から、以下のように画像処理の工程を計画しました。

  1. 輪郭
    1. エッジ検出する
    2. エッジ検出した画像の色を反転する
    3. 白い部分を透過する
  2. 白黒の部分
    1. 画像を三値化する
    2. 灰色の部分を透過する
  3. スクリーントーンの部分
    1. スクリーントーン画像を輪郭画像と白黒部分の画像の最背面に配置する

今回は実際に手を動かして、写真を漫画風に加工するサンプルアプリを作成してみましょう。尚、今回は以下のような開発環境で実装を行います。

  • Mac OS X 10.8.2
  • Xcode 4.5.1
  • iOS SDK 6.0

OpenCVを使えるようにする

いきなりですが、今回の画像処理を行うにあたってOpenCVを使用します。OpenCVは画像処理に関してとても実績のあるライブラリです。OpenCVはインテルが開発したオープンソースのC/C++、Java、Python用ライブラリで、iOS以外にもMac OS Xはもちろん、Linux、Windows、Androidなど様々なプラットフォームで動作します。iOSに関してはむしろ最近標準でサポートされたばかりです。

今回OpenCVを使う理由はエッジ検出を使用するからです。このエッジ検出は他のアルゴリズムと比べると比較的簡単なので、自前で実装してもそんなに苦ではないのですが、そうは言ってもそれなりの行数を記述する必要があります。OpenCVではエッジ検出処理を行う関数が用意されています。また、前回少しだけ紹介したOS X/iOS標準のフレームワークであるCoreImage.frameworkでもエッジ検出がありますが、iOSアプリでは動作しないようになっています(対応していればCoreImage.frameworkを使用したいところですが)。というわけで、今回はOpenCVにあやかっちゃいましょう。どうしても自前で実装したいという方は画像のグレースケール/ネガティブ/エッジ検出処理で、C言語でのエッジ検出処理の実装方法をわかりやすく解説しているのでそちらを参照してください。

OpenCVのダウンロード

SourceForgeからOpenCVのフレームワークをダウンロードします。ダウンロードするバージョンは2.4.3です。OpenCV - Browse /opencv-ios/2.4.3 at SourceForge.netを開き、opencv2.framework.zipをクリックします。ダウンロードしたら、ZIPファイルを解凍しておいてください。

opencv2.frameworkのダウンロード

Xcodeプロジェクトの作成

Xcodeプロジェクトを作成します。テンプレートはSingle View Applicationを選択します。プロジェクトの情報に以下のように入力して任意の場所に保存してください。尚、今回はサンプルですので、保存するときにSource ControlCreate local git repository for this projectのチェックは外しておいてください。

項目 設定値
Product Name MangaFilterTest
Organization Name 任意
Company Identifier 任意
Class Prefix なし
Devices iPhone
Use Storyboards チェックする
Use Automatic Reference Counting チェックする
Include Unit Tests チェックしない

opencv2.frameworkの導入

作成したXcodeプロジェクトにopencv2.frameworkを追加

Xcodeプロジェクトを作成したら、ファイルツリービューのFrameworksを右クリックしてAdd Files to "MangaFilterTest"...をクリックします。ファイル選択画面が表示されるので、先ほどダウンロード・解凍して先ほどダウンロード・解凍したopencv2.frameworkを選択し、Copy items into destination group's folder (if needed)にチェック、Create groups for any added foldersを選択し、Addボタンをクリックしてプロジェクトに追加します。

opencv2.frameworkのインポート

これで、XcodeプロジェクトでOpenCVを使用する準備が整いました。

注意:OpenCV 2.4.2を使用する場合

OpenCV 2.4.2はiOS6(armv7s)に対応していないため、使用する際は注意が必要です。 どうしてもOpenCV 2.4.2を使用したい場合は、以下のサイトで導入方法をわかりやすく解説していますので、ぜひ参考にしてください。 iOSでOpenCVを使えるようにしてみる - FuturesVision

必要なファイルを作成しよう

早速実装作業に入っていきましょう。漫画風のフィルタ処理を実現するため、以下のような工程で画像処理を行います。

今回の目標

上記のように、今回は元画像から輪郭画像と白黒部分の画像をそれぞれ生成し最後に合成するようにします。これらの処理を実現するために、以下のようなクラスを作成します。

クラス 親クラス ファイル 説明
FilterBase NSObject FilterBase.h
FilterBase.mm
フィルタクラスの抽象クラスです。CGImageとOpenCV画像データ間の変換処理を実装します。
LineFilter FilterBase LineFilter.h
LineFilter.mm
輪郭を黒いペンでなぞったような画像を生成します。
MonochromeFilter FilterBase MonochromeFilter.h
MonochromeFilter.mm
白黒部分の画像を生成します。

上記ファイルを作成しましょう。ファイルを作成したら、実装ファイルの拡張子を.mから.mmに変更します。実装ファイルの拡張子を.mmにすると、ファイル中にObjective-CとC++のソースコードを混在させることができます。

FilterBaseクラス

FilterBaseクラスはフィルタクラスの抽象クラスです。CGImageからOpenCV画像データに変換したり、OpenCV画像データからCGImageに変換したりするメソッドを定義しておきます。また、このクラスを継承したサブクラスにてフィルタ処理をかけるときに使用するメソッド- doFilter:も定義しておきます。

FilterBase.h

#import <Foundation/Foundation.h>
#import <opencv2/opencv.hpp>

@interface FilterBase : NSObject

/*!
 @method
 @abstract フィルタ処理を実行するメソッド
 @discussion 引数imageにフィルタ処理したCGImageを返す。
 @param image CGImageRef フィルタ処理をかけるCGImage
 @return CGImageRef フィルタ処理したCGImage
 */
- (CGImageRef)doFilter:(CGImageRef)image;

/*!
 @method
 @abstract CGImageをOpenCV画像データに変換するメソッド
 @param image CGImageRef CGImage
 @return IplImage OpenCV画像データ
 */
- (IplImage *)newIplImageFromCGImage:(CGImageRef)image;

/*!
 @method
 @abstract OpenCV画像データをCGImageに変換するメソッド
 @param image IplImage OpenCV画像データ
 @return CGImageRef CGImage
 */
- (CGImageRef)newCGImageFromIplImage:(IplImage *)image;

@end

FilterBase.mm

#import "FilterBase.h"

@implementation FilterBase

- (CGImageRef)doFilter:(CGImageRef)image
{
    CGImageRetain(image);
    
    // 必要なら呼び出し側でCGImageReleaseによりメモリを解放すること
    return image;
}

- (IplImage *)newIplImageFromCGImage:(CGImageRef)image
{
    CGContextRef context;
    CGColorSpaceRef colorSpace;
    IplImage *iplImageTemp, *iplImage;
    
    // RGB色空間を作成
    colorSpace = CGColorSpaceCreateDeviceRGB();
    
    // 一時的なIplImageを作成
    iplImageTemp = cvCreateImage(cvSize(CGImageGetWidth(image), CGImageGetHeight(image)), IPL_DEPTH_8U, 4);
    
    // CGBitmapContextをIplImageのビットマップデータのポインタから作成
    context = CGBitmapContextCreate(iplImageTemp->imageData,
                                    iplImageTemp->width,
                                    iplImageTemp->height,
                                    iplImageTemp->depth,
                                    iplImageTemp->widthStep,
                                    colorSpace,
                                    kCGImageAlphaPremultipliedLast | kCGBitmapByteOrderDefault);
    
    // CGImageをCGBitmapContextに描画
    CGContextDrawImage(context, CGRectMake(0.0f, 0.0f, CGImageGetWidth(image), CGImageGetHeight(image)), image);
    
    // ビットマップコンテキストと色空間を解放
    CGContextRelease(context);
    CGColorSpaceRelease(colorSpace);
    
    // 最終的なIplImageを作成
    iplImage = cvCreateImage(cvGetSize(iplImageTemp), IPL_DEPTH_8U, 3);
    cvCvtColor(iplImageTemp, iplImage, CV_RGBA2RGB);
    
    // 一時的なIplImageを解放
    cvReleaseImage(&iplImageTemp);
    
    // 必要なら呼び出し側でcvReleaseImageよりメモリを解放すること
    return iplImage;
}

- (CGImageRef)newCGImageFromIplImage:(IplImage *)image
{
    CGColorSpaceRef colorSpace;
    NSData *data;
    CGDataProviderRef provider;
    CGImageRef cgImage;
    
    // RGB色空間
    colorSpace = CGColorSpaceCreateDeviceRGB();
    
    // IplImageのビットマップデータのポインタアドレスからNSDataを作成
    data = [NSData dataWithBytes:image->imageData length:image->imageSize];
    provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)data);
    
    // CGImageを作成
    cgImage = CGImageCreate(image->width,
                            image->height,
                            image->depth,
                            image->depth * image->nChannels,
                            image->widthStep,
                            colorSpace,
                            kCGImageAlphaNone | kCGBitmapByteOrderDefault,
                            provider,
                            NULL,
                            false,
                            kCGRenderingIntentDefault);
    
    CGColorSpaceRelease(colorSpace);
    CGDataProviderRelease(provider);
    
    // 必要なら呼び出し側でCGImageReleaseによりメモリを解放すること
    return cgImage;
}

@end

LineFilterクラス

元画像の輪郭を抽出し、黒いペンでなぞったような画像を生成します。この画像を生成するには前回紹介したエッジ検出を使用します。まずは実際のソースコードを見てみましょう。

LineFilter.h

#import "FilterBase.h"

@interface LineFilter : FilterBase

@end

LineFilter.mm

#import "LineFilter.h"

@implementation LineFilter

- (CGImageRef)doFilter:(CGImageRef)image
{
    // 1.CGImageからIplImageを作成
    IplImage *srcImage       = [self newIplImageFromCGImage:image];
    
    IplImage *grayscaleImage = cvCreateImage(cvGetSize(srcImage), IPL_DEPTH_8U, 1);
    IplImage *edgeImage      = cvCreateImage(cvGetSize(srcImage), IPL_DEPTH_8U, 1);
    IplImage *dstImage       = cvCreateImage(cvGetSize(srcImage), IPL_DEPTH_8U, 3);
    
    // 2.グレースケール画像に変換
    cvCvtColor(srcImage, grayscaleImage, CV_BGR2GRAY);
    
    // 3.グレースケール画像を平滑化
    cvSmooth(grayscaleImage, grayscaleImage, CV_GAUSSIAN, 3, 0, 0);
    
    // 4.エッジ検出画像を作成
    cvCanny(grayscaleImage, edgeImage, 20, 120);
    
    // 5.エッジ検出画像色を反転
    cvNot(edgeImage, edgeImage);
    
    // 6.CGImage用にBGRに変換
    cvCvtColor(edgeImage, dstImage, CV_GRAY2BGR);
    
    // 7.IplImageからCGImageを作成
    CGImageRef effectedImage = [self newCGImageFromIplImage:dstImage];
    
    cvReleaseImage(&srcImage);
    cvReleaseImage(&grayscaleImage);
    cvReleaseImage(&edgeImage);
    cvReleaseImage(&dstImage);
    
    // 8.白色の部分を透過する
    const float colorMasking[6] = {255, 255, 255, 255, 255, 255};
    effectedImage = CGImageCreateWithMaskingColors(effectedImage, colorMasking);
    
    return effectedImage;
}

@end

1.CGImageからIplImageを作成

FilterBaseで定義した- newIplImageFromCGImage:メソッドを使用してCGImageをIplImage(OpenCVで扱う画像データ)に変換します。

2.グレースケール画像に変換

エッジ検出をする場合、カラー画像ではノイズが多くうまく検出できない場合があるため、グレースケール画像や二値化した画像を使用することが多いです。今回はグレースケール画像を使用します。元画像からグレースケール画像を生成するには、void cvCvtColor(const CvArr* src, CvArr* dst, int code)関数を使用します。この関数は主に画像の色空間を変換するために使用します。第1引数srcに対して第3引数codeで指定した色空間に変換したものを第2引数dstに生成します。この段階での画像は以下のようになります。

グレースケールを適用した画像

3.グレースケール画像を平滑化

エッジ検出を行う前にガウシアンフィルタで平滑化を行います。平滑化を行うことによって、画像のノイズを減少させ、安定したエッジ検出を行うことができます。 void cvSmooth(const CvArr* src, CvArr* dst, int smoothtype=CV_GAUSSIAN, int param1=3, int param2=0, double param3=0, double param4=0)関数を使用します。この段階での画像は以下のようになります。

グレースケールに平滑化を適用した画像

4.エッジ検出画像を生成

生成したグレースケール画像を使用してエッジ検出処理を施します。エッジ検出処理はvoid cvCanny(const CvArr* image, CvArr* edges, double threshold1, double threshold2, int aperture_size=3)関数を使用します。cvCanny関数ではCanny法というアルゴリズムを使用してエッジ検出を行います。第3引数threshold1と第4引数threshold2は、小さいほうがエッジ同士を接続するために用いられ,大きいほうが強いエッジの初期検出に用いられます。threshold1、threshold2を変更することで、検出の度合を変更できます。今回はそれぞれ20、120を指定していますが、生成したい画像にあわせて設定しましょう。 この段階での画像は以下のようになります。

エッジ検出を適用した画像

5.エッジ検出画像色を反転

エッジ検出しただけでは今回生成したい画像とは白黒逆になっているので、色を反転させましょう。色を反転するには、void cvNot(const CvArr* src, CvArr* dst)関数を使用します。この段階での画像は以下のようになります。

エッジ検出画像の色を反転した画像

6.CGImage用にBGRに変換

ここまでで生成した画像をCGImage用にBGRに変換します。これを忘れてしまうと大変なことになってしまいます。この処理は先ほど紹介したcvCvtColor関数を使用します。

7.IplImageからCGImageを作成

FilterBaseで定義した- newCGImageFromIplImage:メソッドを使用してIplImageをCGImageに変換します。

.白色の部分を透過する

今回必要なのは、あくまで輪郭の黒い線なので、生成した画像の白い部分を透過します。指定した色を透過するにはCGImageRef CGImageCreateWithMaskingColors(CGImageRef image, const CGFloat components[])関数を使用します。この関数は第1引数imageで指定した画像に対して第2引数componentsで指定した範囲内の色でマスキングします。この段階での画像は以下のようになります。

輪郭画像完成

これで輪郭画像の完成です。

MonochromeFilterクラス

白黒でべた塗りしたような画像を生成します。しかし今回作成したい画像は、白黒だけではなく灰色の部分にスクリーントーンを表示しなければなりません。これは前回紹介した2値化のアルゴリズムを少し改良して、白・黒の2色ではなく白・黒・灰色の3値化を実現しましょう。

MonochromeFilter.h

#import "FilterBase.h"

@interface MonochromeFilter : FilterBase

@end

MonochromeFilter.mm

#import "MonochromeFilter.h"

@implementation MonochromeFilter

- (CGImageRef)doFilter:(CGImageRef)image
{
    // 1.CGImageからIplImageを作成
    IplImage *srcImage       = [self newIplImageFromCGImage:image];
    IplImage *grayScaleImage = cvCreateImage(cvGetSize(srcImage), IPL_DEPTH_8U, 1);
    IplImage *dstImage       = cvCreateImage(cvGetSize(srcImage), IPL_DEPTH_8U, 3);
    
    // 2.グレースケール画像に変換
    cvCvtColor(srcImage, grayScaleImage, CV_BGR2GRAY);
    
    // 3.グレースケール画像を1画素ずつ走査して3値化する
    for(int y = 0; y < grayScaleImage->height; y++) {
        for(int x = 0; x < grayScaleImage->width; x++) {
            int a = grayScaleImage->widthStep * y + x;
            uchar p = grayScaleImage->imageData[a];
            
            if (p < 70) {
                // 70より小さい場合、黒
                grayScaleImage->imageData[a] = 0;
            } else if (70 <= p && p < 120) {
                // 70以上、120未満の場合、灰色
                grayScaleImage->imageData[a] = 100;
            } else {
                // 120以上の場合、白
                grayScaleImage->imageData[a] = 255;
            }
        }
    }
    
    // 4.CGImage用にBGRに変換
    cvCvtColor(grayScaleImage, dstImage, CV_GRAY2BGR);
    
    // 5.IplImageからCGImageを作成
    CGImageRef effectedImage = [self newCGImageFromIplImage:dstImage];
    
    cvReleaseImage(&srcImage);
    cvReleaseImage(&grayScaleImage);
    cvReleaseImage(&dstImage);
    
    // 6.灰色の部分を透過する
    const float colorMasking[6] = {100, 100, 100, 100, 100, 100};
    effectedImage = CGImageCreateWithMaskingColors(effectedImage, colorMasking);
    
    return effectedImage;
}

@end

1.CGImageからIplImageを作成

LineFilterと同じです。FilterBaseで定義した- newIplImageFromCGImage:メソッドを使用してCGImageをIplImage(OpenCVで扱う画像データ)に変換します。

2.グレースケール画像に変換

エッジ検出と同様、画像に2値化処理を施す場合、グレースケール画像を使用することが多いです。なのでここでもグレースケール画像を使用します。この段階での画像は以下のようになります。

グレースケールを適用した画像

3.グレースケール画像を1画素ずつ走査して3値化する

グレースケール画像を1画素ずつ走査して3値化処理を施します。画像サイズはIplImage構造体に定義されているメンバ変数widthとheightから取得できます。画像データのバイト数はメンバ変数widthStepで取得できます。これらの値を使用して画像データへのポインタであるimageDataより1画素ずつ値を変更します。

通常、画像に対して2値化を行う場合、任画素の構成要素RGBの平均値を算出し任意のしきい値より大きければ白、小さければ黒にします。今回は3値化なのでしきい値を2つ用意します。これにより、白・黒・灰色の3色だけで構成された画像を生成することができます。この段階での画像は以下のようになります。尚、灰色はとりあえずR:100、G:100、B:100(#646464)としておきます。

3値化した画像

4.CGImage用にBGRに変換

ここまでで生成した画像をCGImage用にBGRに変換します。これを忘れてしまうと大変なことになってしまいます。この処理は先ほど紹介したcvCvtColor関数を使用します。

5.IplImageからCGImageを作成

FilterBaseで定義した- newCGImageFromIplImage:メソッドを使用してIplImageをCGImageに変換します。

6.白色の部分を透過する

今回は灰色の部分にスクリーントーンを表示するので、生成した画像の灰色の部分を透過します。指定した色を透過するにはCGImageRef CGImageCreateWithMaskingColors(CGImageRef image, const CGFloat components[])関数を使用します。この段階での画像は以下のようになります。

白黒部分の画像完成

スクリーントーン

スクリーントーンの部分は、UIImageViewの背景に以下の画像をタイル状に並べましょう。(スクリーントーン画像のダウンロード)この画像をscreentone.pngとして保存し、Xcodeプロジェクトに追加してください。尚、この画像はStripe Generator - ajax diagonal stripes background designerで生成しました。

スクリーントーンサンプル

合成してみよう

さぁこれでようやく準備が整いました。以下のサンプル画像をsample.jpgとして保存し、Xcodeプロジェクトに追加してください。

サンプル画像

保存できたら、Xcodeプロジェクトに予め作成されているViewController.mをViewController.mmに変更して、以下のように記述しましょう。

ViewController.mm

#import "ViewController.h"

// 作成したフィルタを読み込む
#import "LineFilter.h"
#import "MonochromeFilter.h"

@implementation ViewController

- (void)viewDidLoad
{
    [super viewDidLoad];
    
    // 1.漫画風に加工する画像を読み込む
    UIImage *image = [UIImage imageNamed:@"sample.jpg"];

    // 2.白黒部分の画像を生成する
    MonochromeFilter *monochromeFilter = [[MonochromeFilter alloc] init];
    CGImageRef monocrhomeCGImage = [monochromeFilter doFilter:image.CGImage];
    UIImage *monocrhomeImage = [UIImage imageWithCGImage:monocrhomeCGImage];
    CGImageRelease(monocrhomeCGImage);
    
    // 3.輪郭画像を生成する
    LineFilter *lineFilter = [[LineFilter alloc] init];
    CGImageRef lineCGImage  = [lineFilter doFilter:image.CGImage];
    UIImage *lineImage  = [UIImage imageWithCGImage:lineCGImage];
    CGImageRelease(lineCGImage);

    UIImage *margedImage;
    CGRect imageRect = CGRectMake(0, 0, image.size.width, image.size.height);
    
    // 4.画像を合成する

    // 4-1.オフスクリーン描画のためのグラフィックスコンテキストを用意
    UIGraphicsBeginImageContext(imageRect.size);
    
    // 4-2.白黒部分の画像をコンテキストに描画
    [monocrhomeImage drawInRect:imageRect];

    // 4-3.輪郭画像をコンテキストに描画
    [lineImage drawInRect:imageRect];

    // 4-4.合成画像をコンテキストから取得
    margedImage = UIGraphicsGetImageFromCurrentImageContext();

    // 4-5.オフスクリーン描画を終了
    UIGraphicsEndImageContext();

    // 表示用のUIImageViewを生成
    UIImageView *imageView = [[UIImageView alloc] initWithFrame:self.view.bounds];
    imageView.contentMode = UIViewContentModeScaleAspectFit;

    // 合成画像を設定
    imageView.image = margedImage;
    
    // 5.スクリーントーン画像をUIImageViewの背景パータンとして設定
    imageView.backgroundColor = [UIColor colorWithPatternImage:[UIImage imageNamed:@"screentone.png"]];
    
    // UIImageViewを配置
    [self.view addSubview:imageView];
}

@end

1.漫画風に加工する画像を読み込む

加工対象の画像のCGImageを取得するために、UIImageインスタンスを生成します。

2.白黒部分の画像を生成する

先ほど作成したMonochromeFilterクラスを使用して、白黒部分の画像を生成します。

3.輪郭画像を生成する

先ほど作成したLineFilterクラスを使用して、白黒部分の画像を生成します。

4.画像を合成する

生成した画像を合成します。画像を合成するには、UIGraphicsBeginImageContext(CGSize size)関数でオフスクリーン描画のためのグラフィックスコンテキストを用意して(4-1)、生成した白黒部分の画像と輪郭画像を描画し(4-2、4-3)、できた合成画像をコンテキストから取得します(4-4)。最後にオフスクリーン描画を終了します(4-5)。

5.スクリーントーン画像をUIImageViewの背景パータンとして設定

最後にスクリーントーン画像をUIImageViewの背景パータンとして設定します。

完成!!

動作を確認してみましょう。

動作確認

まとめ

以上、2回に渡り写真を漫画風に加工するための画像処理をお送りしました。この記事ではOpenCVを使用していますが、自前で実装したりCoreImage.frameworkを利用したりするのもありでしょう。この記事をきっかけに面白いorかっちょいいカメラアプリを作って頂ければ幸いです。

  • 通りすがり

    OpenCV2.4.3を使ったios用のコードを探していまして、大変参考になりました、ありがとうございます。そこで質問というか疑問なのですが、表示画像だけCGImageに渡して最後の合成もOpenCVで処理した方がコードも短くなるし処理も早くなるしわかりやすくないですかね?

    • 平井 祐樹

      コメントありがございます!
      ご指摘頂いた通り、リリースを踏まえて実装するのであれば、OpenCV側で画像を合成してあげた方が簡潔&高速化できるかと思います。今回のように実装した背景として、画像処理の手法と実装をわかりやすく解説するためこのような実装になりました。また、今回の場合、輪郭や白黒部分の画像処理部も大したコード数でもないので、例えばLineFilterとMonochromeFilterなどに分けずMangaFilterとしてしまった方が使いやすいかと。FilterBaseで定義されるCGImageとIplImage間の変換用メソッドもユーティリティクラスのクラスメソッドとして定義してしまうのもありかと思います。

  • mm sumi

    OpenCVを使ってみたくてサンプルを探してたどり着きました。
    載せて頂いたコードを元にやってみてはいますが、リンクエラーのようなメッセージが19個位出てきてどうにもなりません。
    出来ればサンプルのプロジェクトを置いて頂くことは出来ないでしょうか?