#13 やってみよう画像処理『カメラのbyte列をいじる』(Androidで)

このエントリーはtech.kayac.com Advent Calendar 2013の13日目のエントリーです。

@DevMassive です。 画像や動画を処理します。

カメラ使ったこと無いよっていうAndroiderはいないと思いますが、 カメラのByte列を扱ったことないderもいるのではないでしょうか。

最近のAndroidはOpenCVやらSurfaceTextureやら、 byte列いじらなくてもカメラを使っていろいろできちゃいますが、

いじっておきましょう!!

というわけで本日はすごく素朴な画像処理アプリをつくります。

カンタンな画像処理

スリットスキャンをつくります!

スリットスキャンを知らない人も多いだろうし、 僕もよく知らないですが、動画を見ればわかります!

これです。

不思議な感じですが、タネを見破ることができたでしょうか?

一番下の行は現在のフレーム、 その上の行は1フレーム前、 その上の行は2フレーム前、

というように表示されているのです。

本日の流れ

  • カメラを扱う
  • byte配列を扱う
  • スリットスキャンにする
  • 興味が湧いたら
  • 次の人

カメラを扱う

Developerサイトのサンプルをベースに進めます。
Cameraの解説

SurfaceViewでリアルタイムにカメラのプレビューが見れる状態(Capturing picturesの手前)まで 実装した状態から、拡張していきます。 CameraActivityでのカメラの開放処理が抜けているので気をつけてください。

byte配列を扱う

さっそく本日のメインです。

色空間

RGBとかHSVとかです。 ある色を数値を使って表すことができます。 同じ色でも、その表し方は色々です☆

RGB色空間が最も有名ですね。

だいたいの人間の網膜には3種類の 錐体細胞 があって、それぞれが赤青緑の色に興奮します。

僕らはそのバランスで色を判断しています。

だからこのディスプレイも赤青緑の光を混ぜて色を表現していますし、 「色空間で最もポピュラーで賞」の座に輝いたのもRGBです。

じゃあRGBだけが正義かというとそんなことはありません。

  • 例えば印刷するとき、RGBのインクはありません。CMY色空間が便利です。
  • パソコンで絵を描いていて、色味をそのままに少し淡くしたいなと思っても、RGBだと面倒です。HSV色空間が便利です。

用途に応じて様々な色空間が生み出されました。

色空間は、他にもいろいろあるよ。
色空間

フォーマット

画像は「色が四角形に並んでいるもの」です。 これをあるフォーマットにしたがってByte列にエンコードします。

基本的な戦略としては、 色空間を用いてそれぞれの色を数字の配列に変換し、 これを規則的に並べてByte列にします。

derに馴染み深いのはRGB_565とかARGB_8888とかでしょう。

カメラからは色空間YCrCbNV21フォーマットで取得できます。 リファレンス

このフォーマットでは、明度(Y)は1ピクセルに1バイト使いますが、 色差(Cr、Cb)は2ピクセルに1バイト使います。 これは人間の目が、明度の変化により敏感であるためです。

また、Byte列には1ピクセルごとのYCrCbの情報がまとまっているのではなく、 最初にすべてのピクセルのY(明度)、そのあとにすべてのCr、すべてのCbと続いています。

今回の素朴な実装では、このNV21フォーマットの画像をふわっと扱います。

byte配列で受け取る

SurfaceView.surfaceChanged() に初期化処理を追加します!

public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
        
        // ...

        // set preview size and make any resize, rotate or
        // reformatting changes here
        // というわけでここに初期化処理を書きましょう
        // これからパラメータをセットするぜ
        Camera.Parameters parameters = mCamera.getParameters();
        // カメラのプレビューサイズ。
        // 設定できる値は機種依存だが、簡略化のため決め打ち
        parameters.setPreviewSize(PREVIEW_WIDTH, PREVIEW_HEIGHT);
        // フォーマット
        // 特に設定しなければNV21になるが、せっかくなので明示的に書くよ
        parameters.setPreviewFormat(ImageFormat.NV21);
        // 以上をカメラに設定だ!
        mCamera.setParameters(parameters);

        // インスタンス生成はあらかじめやっておこう作戦
        // フレームバッファ byte (NV21)
        int size = PREVIEW_WIDTH * PREVIEW_HEIGHT * 
                ImageFormat.getBitsPerPixel(parameters.getPreviewFormat()) / 8;
        mFrameBuffer = new byte[size];

/*         // start preview with new settings         try {             mCamera.setPreviewDisplay(mHolder);             mCamera.startPreview();         } catch (Exception e){             Log.d(TAG, "Error starting camera preview: " + e.getMessage());         }          */
// フレームバッファを追加して、プレビュー開始! mCamera.addCallbackBuffer(mFrameBuffer); mCamera.startPreview(); }

ここで、PREVIEW_WIDTHは320、PREVIEW_HEIGHTは240としました。

で表示するために
CameraPreview クラスに Camera.PreviewCallBack インタフェースを実装します

public class CameraPreview extends SurfaceView 
implements SurfaceHolder.Callback, Camera.PreviewCallback{
    // ...
    public void surfaceCreated(SurfaceHolder holder) {
// The Surface has been created, now tell the camera where to draw the preview. // try { // これの代わりに // mCamera.setPreviewDisplay(holder);
// こっちを使う mCamera.setPreviewCallbackWithBuffer(this); mCamera.startPreview();
// } catch (IOException e) { // Log.d(TAG, "Error setting camera preview: " + e.getMessage()); // }
} public void surfaceDestroyed(SurfaceHolder holder) { // empty. Take care of releasing the Camera preview in your activity. mCamera.setPreviewCallback(null); }

これを実装することで、プレビュー中に

mCamera.addCallbackBuffer(mFrameBuffer);

としておけば、フレームがぎっちぎちに詰め込まれたmFrameBuffer を引数にした onPreviewFrame() が呼ばれます!

    // このdataこそが求めていたbyte配列だ!!
    public void onPreviewFrame(byte[] data, Camera camera) {
    
        // ここに描画処理

        // バッファに追加することで、次のフレームを待ち受けます
        mCamera.addCallbackBuffer(mFrameBuffer);
    }

配列を受け取れました!

あれ!?!?画像は2次元じゃないの!?!?と動揺しがちですが、 1行目の次に2行目がくっついてきているだけです落ち着いてください。

でこのdataNV21フォーマットなので、最初の幅×高さバイトに明度の情報(Y)が入っているわけです。

さっそく中身を表示してみましょう!

    public void onPreviewFrame(byte[] data, Camera camera) {
        // byte[]をint[]に変換する(明度のみ用いる)
        int[] frame = mGrayResult;
        for (int i = 0; i < frame.length; i ++) {
            int gray = data[i] & 0xff;
            // 明度をARGBに変換します
            // 例えば明度が30なら、A=255, R=30, G=30, B=30としています
            frame[i] = 0xff000000 | gray << 16 | gray << 8 | gray;
        }
        
        // int[]をBitmapに変換する
        mBitmap.setPixels(mGrayResult, 0, PREVIEW_WIDTH, 0, 0, 
                PREVIEW_WIDTH, PREVIEW_HEIGHT);

        // SurfaceViewにBitmapをただ描画する
        Canvas canvas = mHolder.lockCanvas();
        canvas.drawBitmap(mBitmap, 0, 0, null);
        mHolder.unlockCanvasAndPost(canvas);

        // ...
    }

mGrayResult, mBitmapを、 フレームを取得するたびにnewしまくるのは恐ろしいので、 初期化処理に追加しておきます。

public void surfaceChanged(SurfaceHolder holder, int format, int w, int h) {
        
        // ...

        // グレースケール画像 byte (NV21) -> int (ARGB_B888)
        mGrayResult = new int[PREVIEW_HEIGHT * PREVIEW_WIDTH];

        // 描画するBitmap int (ARGB_8888) -> Bitmap
        mBitmap = Bitmap.createBitmap(
                PREVIEW_WIDTH, PREVIEW_HEIGHT, Config.ARGB_8888);
                
        // ...
    }

するとこんなかんじになります。

slit_scan_test.jpg

byte配列が画像になりました!わーい!

スリットでスキャンする

過去フレームを保持

スリットスキャンをつくりたいので、 過去フレームも保持しておく必要があります。

初期化処理に以下を追加します。

        // 保持しておく画像たち
        int len = PREVIEW_HEIGHT/SLIT_HEIGHT;
        mGrayList = new ArrayList<int[]>(len);
        for (int i = 0; i < len; i++) {
            mGrayList.add(new int[PREVIEW_HEIGHT * PREVIEW_WIDTH]);
        }
        // 保持しておく画像たちを使いまわすためのインデクス
        mCurrentIndex = 0;

ここでSLIT_HEIGHTは4としました。

表示する

描画部分はこんなかんじになります。

        // ルックアップテーブル(LUT)をつくっておきます
        // 明度をARGBの値にマップします
        private static final int[] GRAY_ARGB_LUT = new int[256];
        static {
            for (int i = 0; i < 256; i++) {
                GRAY_ARGB_LUT[i] = 0xff000000 | i << 16 | i << 8 | i;
            }
        }
                                                
        public void onPreviewFrame(byte[] data, Camera camera) {
        int[] frame = mGrayList.get(mCurrentIndex);
        for (int i = 0; i < frame.length; i ++) {
            int gray = data[i] & 0xff;
            frame[i] = GRAY_ARGB_LUT[gray];
        }

        // 1スリット(nピクセル)ずつ配列をコピーします
        int n = PREVIEW_WIDTH * SLIT_HEIGHT;
        int p = 0;
        for (int i = 0; i < mGrayList.size(); i ++) {
            int index = (mCurrentIndex + 1 + i) % mGrayList.size();
            int[] f = mGrayList.get(index);
            System.arraycopy(f, p, mGrayResult, p, n);
            p += n;
        }

        mBitmap.setPixels(mGrayResult, 0, PREVIEW_WIDTH, 0, 0, 
                PREVIEW_WIDTH, PREVIEW_HEIGHT);

        Canvas canvas = mHolder.lockCanvas();
        canvas.drawBitmap(mBitmap, 0, 0, null);
        mHolder.unlockCanvasAndPost(canvas);

        mCamera.addCallbackBuffer(mFrameBuffer);
        
        // インデクスをずらします
        mCurrentIndex = (mCurrentIndex + 1) % mGrayList.size();
    }

これで撮影した動画がこちらです。

@shogo82148 の後頭部です。

興味が湧いたら

紹介できなかったこと

今回はグレースケールの画像を扱いましたが、 NV21をARGB8888に変換 してカラー画像としても扱えます。

JNIやOpenGLを用いて高速化したりするのも楽しいですね。 フレームをそのままテクスチャとして保存しておき、スリットを描画したらとても快適に動きましたよ。

画像、どうでしょう。

画像処理はちょっとの工夫で、おもしろい見た目になりますね!!

ただ、説明しといてあれですが、画像をByte列で扱ってると、めんどうです。

例えば、ぼかそうと思っても、めんどうです。

OpenCVを使えばどこかのすごいおじさんがつくったやつがタダでつかえるから、つかうといいです。 Androidにもカンタンに導入できるので試してみたことないひとは ググってください。

Byte列を扱うよりカンタンなのでここまで読んだderは楽勝だと思います。

僕は今まで、C++で作った画像処理プログラムをパソコンで実行していました。
Android端末があれば、どこでも気軽に、(ある程度は)リアルタイムで画像を処理できちゃう!
わくわくがとまりません。

次の人

明日は物知り競技プログラマ @shogo82148 です。

すてきな後頭部をもっているshogoさん、明日はどんな記事を書いてくれるのでしょうか!
僕も楽しみです!