Help us understand the problem. What is going on with this article?

[Objective-C] AVCaptureとvImageを使ってリアルタイムフィルタを作ってみる

More than 5 years have passed since last update.

vImageを使うと、より複雑なフィルターなどを自前で実装できます。
今回チャレンジしてみて、色々とハマったので理解した部分をまとめておこうと思います。
色々検索してみましたが、あまり解説している記事が少なかった印象です。
なので、今後やろうとしている人のなにかしらの役に立てれば。

準備

今回のサンプルではAccelerate.frameworkAVFoundation.frameworkを使うので、それをインポートします。

#import <Accelerate/Accelerate.h>
#import <AVFoundation/AVFoundation.h>

AVFoundationのセットアップ手順

まず、AVFoundationを使ってカメラを制御します。
カメラにアクセスする方法はいくつかあり、ちょっと古い記事ですがそれらをこちらの記事がまとめてくれているので、見ておくといいと思います。

簡単に手順を書くと、

  1. デバイスを取得する
  2. インプット、アウトプット用オブジェクトを生成する
  3. セッションオブジェクトを作成し、インプットとアウトプットをつなぐ
  4. デリゲートメソッドを実装し、動画のキャプチャ情報を加工する
  5. 加工後のイメージを画面に表示する

という流れになります。
セッション周りについては、Appleのプログラミングガイドを見ると図説してくれているので分かりやすいです。(AV Foundationプログラミングガイド

一言で説明すると、カメラ(インプット)と画像(アウトプット)をいい感じにつないでくれるのがセッションです。

余談ですが、セッションの意味を調べると「開会・集会などを意味する英語」と書かれています。つまり、インプットとアウトプットの集まり、ということでしょう。

セッションのセットアップ

セッションの作成

上記の手順に従って、セットアップのコードを解説します。
まずはセッションを作成します。

self.captureSession = [[AVCaptureSession alloc] init];

カメラデバイスの取得

カメラを取得します。カメラは前面・背面あるのでそれぞれ個別に取得します。

NSArray *devices = [AVCaptureDevice devices];
AVCaptureDeviceInput *frontInput;
AVCaptureDeviceInput *backInput;

for (AVCaptureDevice *device in devices) {
    if ([device hasMediaType:AVMediaTypeVideo]) {
        NSError *error = nil;

        if (device.position == AVCaptureDevicePositionFront) {
            frontInput = [AVCaptureDeviceInput deviceInputWithDevice:device error:&error];
        }
        else {
            backInput = [AVCaptureDeviceInput deviceInputWithDevice:device error:&error];
        }
    }
}

hasMediaType:メソッドで判定しているのは、カメラ以外にもマイクなどがdevicesに含まれているためです。

インプット、アウトプットをセッションに追加

[self.captureSession addInput:backInput];

NSDictionary *settings;
settings = @{(NSString *)kCVPixelBufferPixelFormatTypeKey:@(kCVPixelFormatType_32BGRA)};

self.videoOutput = [[AVCaptureVideoDataOutput alloc] init];
[self.videoOutput setVideoSettings:settings];
[self.videoOutput setAlwaysDiscardsLateVideoFrame:YES];

[self.captureSession addOutput:self.videoOutput];

カメラの向きを設定

カメラの画角は基本は横向きのようなので、縦に取りたい場合は設定を変更する処理を書かないとなりません。
変更にはAVCaptureConnectionオブジェクトのsetVideoOrientation:メソッドに適切な引数を渡します。

// カメラの向きを設定
AVCaptureConnection *videoConnection = nil;

[self.captureSession beginConfiguration];

for (AVCaptureConnection *connection in [self.videoOutput connections]) {
    for (AVCaptureInputPort *port in [connection inputPorts]) {
        if ([[port mediaType] isEqual:AVMediaTypeVideo]) {
            videoConnection = connection;
        }
    }
}

if ([videoConnection isVideoOrientationSupported]) {
    [videoConnection setVideoOrientation:AVCaptureVideoOrientationPortrait];
}

[self.captureSession commitConfiguration];

デリゲートをセット

dispatch_queue_t queue = dispatch_queue_create("VideoQueue", DISPATCH_QUEUE_SERIAL);
[self.videoOutput setSampleBufferDelegate:self queue:queue];

シリアルキューを生成して、デリゲート設定時にキューを渡してやります。
シリアルキューなのは、動画のフレームが順番に適切にデリゲートメソッドに渡されることを保証するためです。

プレビューレイヤーを生成

self.previewLayer = [AVCaptureVideoPreviewLayer layer];
self.previewLayer.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
self.previewLayer.position = CGPointMake(self.view.frame.size.width / 2, self.view.frame.size.height / 2);

[self.view.layer addSublayer:self.previewLayer];

これでひと通り、カメラと画面に出力する部分までのセットアップが終わりました。
ただ、これだけだとまだなにも画面に表示されません。
続いて、動画の毎フレームごとにキャプチャ画像が送られてくるデリゲートメソッドを実装します。

※AffineTransformを設定していましたが、カメラの向きを設定していないのが理由だったのでサンプルから削除しました。

デリゲートメソッドを実装する

実装するデリゲートメソッドはAVCaptureVideoDataOutputSampleBufferDelegateプロトコロに定義されているcaptureOutput:didOutputSampleBuffer:fromConnection:メソッドです。
動画のキャプチャ画像がフレームごとに送られてきます。

イメージバッファの取得とロック

CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
CVPixelBufferLockBaseAddress(imageBuffer, 0);

まず、CMSampleBufferRefからCVImageBufferRefを取得し、ピクセルバッファにアクセスできるようにします。
取得後、そのバッファをロックします。
これは、次にキャプチャが送られてきたときに該当アドレスの内容が書き換わってしまうのを防ぐためです。

バッファ情報を収集

size_t width  = CVPixelBufferGetWidth(imageBuffer);
size_t height = CVPixelBufferGetHeight(imageBuffer);
size_t bytesPerRow = CVPixelBufferGetBytesPerRow(imageBuffer);
const size_t bufferSize = CVPixelBufferGetDataSize(imageBuffer);

CVPixelBufferGet***メソッドで必要な情報を集めます。
(ちなみにCVCore Videoの略)

入力用・出力用のvImage_Bufferを作成する

必要な情報が集まったらvImage_Bufferを、入出力用に生成します。

// 入力用
uint_8 *buffer = (uint8_t *)CVPixelBufferGetBaseAddress(imageBuffer);
const vImage_Buffer inputvImage = {buffer, height, width, bytesPerRow};

// 出力用
void *outBuffer = malloc(bufferSize); // 出力用に空のメモリを確保
const vImage_Buffer outputvImage = {outBuffer, height, width, bytesPerRow};

入力用については、メソッドの引数で渡ってきたバッファを元に生成します。
出力用については(今回はフィルタをかけるので)必要なメモリを確保するだけです。

フィルターをかける

フィルターについては後半で解説します。

gaussianblur(&inputvImage, &outputvImage);

ビットマップを生成する

上記まででバッファ自体にフィルターをかけた情報が整いました。
あとはこれを見える画像として出力してやります。
出力にはCGImageを使います。

// ビットマップコンテキストの生成
CGBitmapInfo bitmapInfo    = kCGBitmapByteOrder32Little | kCGImageAlphaPremultipledFirst;
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGContextRef cgContext     = CGBitmapContextCreate(outputvImage.data, width, height, 8, bytesPerRow, colorSpace, bitmapInfo);

// 画像の作成
CGImageRef dstImage = CGBitmapContextCreateImage(cgContext);

__typeof(self) __weak wself = self;
dispatch_sync(dispatch_get_main_queue(), ^{
    wself.previewLayer.contents = (__bredge id)dstImage;
});

// 解放処理
free(outBuffer);
CGImageRelease(dstImage);
CGContextReleaes(cgContext);
CGColorSpaceRelease(colorSpace);

// バッファのアンロック
CVPicelBufferUnlockBaseAddress(imageBufer, 0);

dispatch_get_main_queueを利用しているのは、メインスレッドでないと画面の更新ができないためです。

フィルターについて

さて、解説を飛ばしていたフィルターについてですが、今回のサンプルでは以下のように書いています。

void gaussianblur(const vImage_Buffer *src, const vImage_Buffer *dst) {
    const int16_t kernel[] = {
       1,  4,  6,  4, 1,
       4, 16, 24, 16, 4,
       6, 24, 36, 24, 6,
        4, 16, 24, 16, 4,
        1,  4,  6,  4, 1
    };

    int length = sizeof(kernel) / sizeof(int16_t);
    int32_t divisor = 0;

    for (int i = 0; i < length; i++) {
        divisor += kernel[i];
    }

    unsigned int size = sqrt(length);
    uint32_t kernel_height = size;
    uint32_t kernel_width  = size;
    vImageConvolve_ARGB8888(src, dst, NULL, 0, 0, kernel, kernel_height, kernel_width, divisor, NULL, kvImageCopyInPlace);
}

これは、畳み込み演算という処理で、画像に対して演算を行うものです。
畳み込み処理についてはこちらの記事が分かりやすいでしょう。
また、この他フィルターなどについて詳しく解説してくれている記事があるので、そちらも見てみてください。

以上で、画面に配置されたビューにフィルタがかかった動画が表示されるようになると思います。
まだまだフラグの意味など分からない点も多いですが、とりあえず画像をキャプチャし、それにフィルタをかける、というところまでは実現できました。
(実はまだ、画像のアスペクト比がおかしい、っていう問題が残ってたりしますが・・)
AVCaptureConnectionsetVideoOrientation:メソッドに設定していなかったのが原因でした。
サンプルに追記しています。

その他、型や定義などの補足

型や定義が分からないと全体像をイメージしづらかったので、気になった点について調べてみました。

それぞれの型の定義

解説で使った方については以下のように定義がされていました。

定義 補足
int16_t short
int32_t int
Pixel_8 uint8_t
uint8_t unsigned char
Pixel_8888[4] uint8_t ARGBの4要素
vImage_Buffer -- 構造体。rowBytesは画像の 1行分 のバイト数です。
渡す値を計算する際はwidth * 4のようにして、幅 * bytesにします。
vImagePixelCount unsigned long
size_t 処理系を考慮したint型? 参考

実際の定義一覧

typedef short int16_t;

typedef int int32_t;

/* 8 bit planar pixel value */
typedef uint8_t Pixel_8;

typedef unsigned char uint8_t;

/* ARGB interleaved (8 bit/channel) pixel value. uint8_t[4] = { alpha, red, green, blue } */
typedef uint8_t Pixel_8888[4];

/* Pedantic: A number of pixels. For LP64 (ppc64/x86_64) this is a 64-bit quantity.  */
typedef unsigned long vImagePixelCount;

typedef struct vImage_Buffer
{
    void                *data;    /* Pointer to the top left pixel of the buffer.    */
    vImagePixelCount    height;   /* The height (in pixels) of the buffer        */
    vImagePixelCount    width;    /* The width (in pixels) of the buffer         */
    size_t              rowBytes; /* The number of bytes in a pixel row, including any unused space between one row and the next. */
} vImage_Buffer;

/* Pedantic: A number of pixels. For LP64 (ppc64/x86_64) this is a 64-bit quantity. */
typedef unsigned long vImagePixelCount;

vImageConvolve_ARGB8888

畳み込み演算をする要のvImageConvolve_ARGB8888は以下のような引数を取ります。

vImageConvolve_ARGB8888(
    &src,              // const vImage_Buffer *src
    &dst,              // const vImage_Buffer *dest
    NULL,              // void *tempBuffer
    0,                 // vImagePixelCount srcOffsetToROI_X
    0,                 // vImagePixelCount srcOffsetToROI_Y
    kernelArray,       // const int16_t *kernel
    3,                 // uint32_t kernel_height
    3,                 // uint32_t kernel_width
    divisor,           // int32_t divisor
    NULL,              // Pixel_8888 backgroundColor
    kvImageCopyInPlace // vImage_Flags flags
);

kernel

ちなみにサンプルコードで使用しているkernelですが、意味は「核心、核」という意味。
処理の「核」を担っている、ってことかなーと勝手に解釈してます。
つまり畳み込み演算の核、というわけですね。
どういう演算をするか、というのがkernelの配列によって決まるわけです。

divisor

「除数」という意味の英単語。
畳み込み演算の際に、正規化する単位として使われるようです。

ハマったポイント

今回のサンプルを作っていく上で、いくつかエラーに遭遇したのでそのメモ。

CGBitmapContextCreateでのエラー

<Error>: CGBitmapContextCreate: invalid data bytes/row: should be at least 1920 for 8 integer bits/component, 3 components, kCGImageAlphaPremultipliedFirst.

理由はまだしっかり理解していないんですが、おそらく内容を見るに1行あたりのデータサイズが、実際と引数で渡されているものとが違う、ということのようです。
このあたりはBitmapInfoのフラグが適切じゃないと出るみたいです。フラグはまだしっかり理解できていないので、プログラミングガイドとかから拾ってきたやつを設定して、うまく動いたものを使ってます。

EXC_BAD_ACCESSが出る

これはだいぶマヌケな話ですが、変なところでメモリが解放されているわけでもなく、なんでかなーと思っていたら、たんにメモリの割り当て量の計算をミスっていた、というもの;
今回、出力用のvImage_Buffermalloc関数によって確保していたので、ここのサイズ計算でミスしてました。
色々な記事を参考に自前で計算していたんですが、そもそもデータサイズを得る関数があったのでそれを使ってます。

// 実際に使ってるやつ
const size_t bufferSize = CVPixelBufferGetDataSize(imageBuffer);

// 以下のように自前で計算してもOK
const size_t bufferSize = sizeof(uint8_t) * width * height * 4;

最初、width * heightのみで、* 4をしていなかったのが原因でした。
4は、各種ピクセルカラーが32bit = 4bytes、ということです。

参考記事

edo_m18
現在はUnity ARエンジニア。 主にARのコンテンツ制作をしています。 最近は機械学習にも興味が出て勉強中です。 Unityに関するブログは別で書いています↓ https://edom18.hateblo.jp/
http://edom18.hateblo.jp/
unity-game-dev-guild
趣味・仕事問わずUnityでゲームを作っている開発者のみで構成されるオンラインコミュニティです。Unityでゲームを開発・運用するにあたって必要なあらゆる知見を共有することを目的とします。
https://unity-game-dev-guild.github.io/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした