AndroidのCamera2 APIとOpenGL ESでカメラの映像を表示してみた

この記事は tech.kayac.com Advent Calendar 2015の6日目のエントリーです。

こんにちは。今年の4月にカヤックに新卒で入社した下田と申します。 今年の6月からAndroid開発に携わるようになりました。まだペーペーのAndroiderです。

この記事では、Android 5.0より使えるCamera2 APIとOpenGL ESでカメラ映像を扱う話を書かせていただきます。

なぜOpenGL ES?

現在、僕はスマホゲーム開発をサポートするLobi-SDKの開発に携わっています。 Lobi-SDKの中には、スマホゲームの画面を録画するというミラクルな機能を提供するRec SDKがありますが、その一機能に、端末のフロントカメラを利用し、ゲームをプレイ中のユーザーの顔を画面中に表示するワイプ実況機能というものがあります。 Android向けのRec SDKでは、11月頃にそのワイプ実況機能をリリースしましたが、僕はその開発に参加していました。 その中でOpenGL ESによるカメラ映像の描画を扱ったので、ここでそれ関連の話を書いてみようと思いました。

OpenGL ES、みなさんご存知でしょうか。 OpenGLというグラフィックライブラリがあり、OpenGL ESはその組み込み機器向け用のサブセットになります。

Androidでは、Viewによる画面の描画が最も標準的な描画方法です。 この方法でも、よっぽどグラフィック処理に負荷がかからない描画であれば問題はないでしょう。 しかし例えば、何枚もの大きい画像やら動画やらを扱うとなれば、どうしても描画速度が落ちてしまいます。 そんなときに使いたいのがOpenGL ESです。Viewによる描画よりも素晴らしい描画パフォーマンスが期待できます。

なぜCamera2 API?

当初はここでこのAPIを扱うつもりはありませんでした。 OpenGL ESとカメラの映像を使って何かやろうかと考え、とりあえず以前のカメラの処理のプログラムを持ってきて、それでちょっとしたアプリでも作ろうかな、と思っていましたが、その時に問題が発生したのです。 それまでカメラの処理に使っていたandroid.hardware.Cameraがdeprecateになっていていたのです。Android5.0からカメラAPIの仕様が変わり、Camera2 APIが使えるようになったのでそっちを使ってよ、とのことでした。

新しいものが出てきてしまったならそれでやった方がいいか、と思ってこのAPIに手をつけてみることにしました。


というわけで、以下、本題になります。 流れとしては、

  1. とりあえずCamera2 APIを使い、簡単な方法でカメラの映像を表示してみる。
  2. それをOpenGL ESを使って表示してみる。

となります。

とりあえず新しいカメラAPIを使って、カメラの映像を表示してみる

カメラを使うために必要なのは次の2つです。

  • カメラそのもの
  • カメラの映像を貼り付けるためのテクスチャ(+それを扱うビュー)

カメラの起動

まずカメラの起動についてです。バックカメラ(スクリーンの反対側のカメラ)を起動するには、次のようにします。

CameraManager manager = (CameraManager) mActivity.getSystemService(Context.CAMERA_SERVICE);
for (String cameraId : manager.getCameraIdList()) {
    CameraCharacteristics characteristics = manager.getCameraCharacteristics(cameraId);
    if (characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK) {
        manager.openCamera(cameraId, mCameraDeviceCallback, null);
        return;
    }
}

CameraManagerなるものを取得し、それから目的のカメラのCameraIdを見つける形です。 今回はバックカメラを取得していますが、フロントカメラ(スクリーン側)を使いたい場合、上記のif文条件中のLENS_FACING_BACKLENS_FACING_FRONTとすればOKです。

mCameraDeviceCallbackは、カメラ起動時や、カメラとの接続が切れてしまった時に呼ばれるコールバックを設定するリスナです。この後に示すソースコード中に出てきます。

テクスチャについて

続いてテクスチャです。TextureViewというテクスチャを扱うビューを利用します。テクスチャにカメラの映像を表示するためには、2つを紐付けなければなりません。 次のような流れでそれを実現します。

  1. テクスチャを用意する。
  2. テクスチャに描画する準備が整ったら、カメラを起動する。
  3. カメラが起動できたら、テクスチャとカメラを紐付ける。
  4. 紐づが終わったら、映像を表示する。

ソースコード

ということで、カメラ映像を表示する最低限の処理のみを行ったソースコードを載せてみます。

  • MainActivity.java
package ...

import ...

public class MainActivity extends Activity {
    private Camera mCamera;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        TextureView textureView = (TextureView) findViewById(R.id.texture_view);
        textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
            @Override
            public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
                mCamera.open();
            }

            @Override
            public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) {}

            @Override
            public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
                return false;
            }

            @Override
            public void onSurfaceTextureUpdated(SurfaceTexture surface) {}
        });

        mCamera = new Camera(textureView);
    }

    class Camera {
        private CameraDevice mCamera;
        private TextureView mTextureView;
        private Size mCameraSize;
        private CaptureRequest.Builder mPreviewBuilder;
        private CameraCaptureSession mPreviewSession;

        private CameraDevice.StateCallback mCameraDeviceCallback = new CameraDevice.StateCallback() {
            @Override
            public void onOpened(@NonNull CameraDevice camera) {
                mCamera = camera;
                createCaptureSession();
            }

            @Override
            public void onDisconnected(@NonNull CameraDevice camera) {
                camera.close();
                mCamera = null;
            }

            @Override
            public void onError(@NonNull CameraDevice camera, int error) {
                camera.close();
                mCamera = null;
            }
        };

        CameraCaptureSession.StateCallback mCameraCaptureSessionCallback = new CameraCaptureSession.StateCallback() {
            @Override
            public void onConfigured(@NonNull CameraCaptureSession session) {
                mPreviewSession = session;
                updatePreview();
            }

            @Override
            public void onConfigureFailed(@NonNull CameraCaptureSession session) {
                Toast.makeText(MainActivity.this, "onConfigureFailed", Toast.LENGTH_LONG).show();
            }
        };

        public Camera(TextureView textureView) {
            mTextureView = textureView;
        }

        public void open() {
            try {
                CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);
                for (String cameraId : manager.getCameraIdList()) {
                    CameraCharacteristics characteristics = manager.getCameraCharacteristics(cameraId);
                    if (characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_BACK) {
                        StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
                        mCameraSize = map.getOutputSizes(SurfaceTexture.class)[0];
                        manager.openCamera(cameraId, mCameraDeviceCallback, null);

                        return;
                    }
                }
            } catch (CameraAccessException e) {
                e.printStackTrace();
            }
        }

        private void createCaptureSession() {
            if (!mTextureView.isAvailable()) {
                return;
            }

            SurfaceTexture texture = mTextureView.getSurfaceTexture();
            texture.setDefaultBufferSize(mCameraSize.getWidth(), mCameraSize.getHeight());
            Surface surface = new Surface(texture);
            try {
                mPreviewBuilder = mCamera.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
            } catch (CameraAccessException e) {
                e.printStackTrace();
            }

            mPreviewBuilder.addTarget(surface);
            try {
                mCamera.createCaptureSession(Collections.singletonList(surface), mCameraCaptureSessionCallback, null);
            } catch (CameraAccessException e) {
                e.printStackTrace();
            }
        }

        private void updatePreview() {
            mPreviewBuilder.set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE);
            HandlerThread thread = new HandlerThread("CameraPreview");
            thread.start();
            Handler backgroundHandler = new Handler(thread.getLooper());

            try {
                mPreviewSession.setRepeatingRequest(mPreviewBuilder.build(), null, backgroundHandler);
            } catch (CameraAccessException e) {
                e.printStackTrace();
            }
        }
    }
}
  • activity_main
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context="ueder.cameratest.MainActivity">

    <TextureView
        android:id="@+id/texture_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</FrameLayout>

長いですね。前のカメラAPIでは、同じこと(ただカメラの映像を表示するだけ)が1/3ぐらいでできた気もしました。

しかし処理の流れが把握しやすいと思いました。 例えばMainActivity#onCreateの中のここ。

textureView.setSurfaceTextureListener(new TextureView.SurfaceTextureListener() {
    @Override
    public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
        mCamera.open();
    }
    ...
});

使用するTextureViewにコールバックを割り当て、テクスチャの描画準備が整ったら、カメラを起動するmCamera.open()を呼ぶようにしています。 このような感じで、テクスチャの準備やカメラの設定完了などのタイミングで呼ばれるコールバックが設定できるので、カメラやテクスチャの状態の管理も大変ではなさそうでした。

上のコード実行時のスクリーンショットがこちらです。

Screenshot_20151206-223731.png

ただ、カメラの映像を表示しただけなので何の変哲も無いです。 そして、端末を横に傾けたとき(landscapeにしたとき)がこちらです。

Screenshot_20151206-223815.png

映像の向きやアスペクト比がおかしくなります。全然役立たずです。

これを直したいとこですが、メインの話ではないので、次に進みます。

OpenGLESでカメラの映像を描画する

さて、カメラの映像をプレビューできるようになったので、今度はそれをOpenGLESを使って描画してみたいと思います。

冒頭の方で、カメラの映像をプレビューするのに必要なもの2つとして

  • カメラそのもの
  • カメラの映像を貼り付けるためのテクスチャ(+それを扱うビュー)

と書きましたが、2つ目に(+それを扱うビュー)としているので、実質3つになっていました。 そう、Rendererという描画をやってくれるやつが必要です。 上に示したプログラムでは、それを全部TextureViewがやってくれましたが、そのかわりOpenGL ESを使って、自分で描画処理を書いてみます。

ソースコード

といきたいところですが、大分長くなってしまうのでgithubにあげることにしました(https://github.com/shimoda-tomoaki/CameraTestProject)。

やったことを簡単にまとめますと、 1. GLThreadという独自のレンダリンクスレッドを扱うGLSurfaceViewというものを用い、 2. テクスチャを生成してカメラに渡し、カメラに映像をそのテクスチャを流してもらいつつ、 3. GLSurfaceView内でそのテクスチャを描画 という感じです。

実装できたアプリはこんな感じになりました。

Screenshot_20151206-223914.png

カメラの映像と端末の画面の縦横比が違うため、映像をきっちり画面内に抑えようとすると、どうしても余りが出てきてしまします。

横画面はこんな感じです。

Screenshot_20151206-223926.png

映像の向きやアスペクト比の調整もしています。

ということで、OpenGL ESでの描画処理についての解説が全然できませんでしたが、最後に、自分で実際にでカメラ映像を描画するRendererを作るにあたって、「ん?」となった点について記します。

最も「ん?」となったのはOpenGL ESの仕組みについてですが、ここではとても扱いきれないので、その次点を書きます。

Androidのカメラの映像について

上の方でも出てきましたが、端末の傾きとカメラの映像の向きの話です。

Androidのカメラは、landscapeの状態がデフォルトのようです。 そのため、アプリがlandscapeでない状態でそのままカメラの映像を描画すると、 横に回転した映像になってしまいます。

なのでportraitlandscapereverse portraitreverse landscapeのどんなときでも正しい方向で映像を描画するには、端末がどの状態であるかを把握し、それに合わせてテクスチャの描画の向きを変えなければなりません。

そのために使うのが、

mActivity.getWindowManager().getDefaultDisplay().getRotation()

で取得できる値です。

この値は端末がデフォルトの状態からどれだけ傾いているかを表し、Surface.ROTATION_0Surface.ROTATION_90Surface.ROTATION_180Surface.ROTATION_270のいずれかを得られます。 スマホでは、portraitがデフォルトの状態なので、そのときgetRotation()Surface.ROTATION_0を返します。 そこから端末を反時計周りに回転させるごとに、90_180_270となっていきます。

さてこれで、getRotation()を使って映像を正しく描画できる......と思ったら、まだ問題がありました。 上記のgetRotation()、なんとタブレットでは値が変わるのです。 タブレットはlandscape状態がデフォルトなので、landscapeのときにgetRotation()Surface.ROTATION_0になるのです! ややこしいです。

ということで、githubに上げたものでは、カメラ映像がどれだけ回転しているかの判定をこのようにしています。

int orientation = mActivity.getWindowManager().getDefaultDisplay().getRotation();
switch(orientation) {
    case Surface.ROTATION_0:
        mCameraRotation = mIsPortraitDevice ? CameraRotation.ROTATION_270 : CameraRotation.ROTATION_0;
        break;
    case Surface.ROTATION_90:
        mCameraRotation = mIsPortraitDevice ? CameraRotation.ROTATION_0 : CameraRotation.ROTATION_90;
        break;
    case Surface.ROTATION_180:
        mCameraRotation = mIsPortraitDevice ? CameraRotation.ROTATION_90 : CameraRotation.ROTATION_180;
        break;
    case Surface.ROTATION_270:
        mCameraRotation = mIsPortraitDevice ? CameraRotation.ROTATION_180 : CameraRotation.ROTATION_270;
        break;
}

端末が回転するたびに、上記を行い、カメラ映像の回転具合を示すmCameraRotationを更新しています。

  • mIsPortraitDeviceは、portraitがデフォルトである端末(スマホやファブレット)ではtrueportraitがデフォルトである端末(タブレット)ではfalseを返すようにしています。
  • CameraRotation.ROTATION_XXは、カメラの映像が端末の状態に対してXX°だけ反時計回りに傾いていることを示しています。

面倒でした!

明日は

カヤックのエンジニアのボス、組長こと@sfujiwaraさんのお話です。楽しみですね!