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](https://developer.lobi.co/ja)の開発に携わっています。
[Lobi-SDK](https://developer.lobi.co/ja)の中には、スマホゲームの画面を録画するというミラクルな機能を提供する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に手をつけてみることにしました。

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

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

となります。

# とりあえず新しいカメラAPIを使って、カメラの映像を表示してみる
カメラを使うために必要なのは次の2つです。

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

## カメラの起動

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

```java
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_BACK`が`LENS_FACING_FRONT`とすればOKです。

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

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

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


## ソースコード

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

```java: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();
            }
        }
    }
}
```

```xml:activity_main.xml
<?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`の中のここ。

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

    ...
});
```

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

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

![Screenshot_20151206-223731.png](https://qiita-image-store.s3.amazonaws.com/0/86961/44c8abd1-f376-e064-619d-5240c0a29ca6.png)

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

![Screenshot_20151206-223815.png](https://qiita-image-store.s3.amazonaws.com/0/86961/460ec22e-80ad-7a05-5432-60498cd61bff.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](https://qiita-image-store.s3.amazonaws.com/0/86961/cad6303f-6641-7301-0607-1b0b25083db9.png)

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

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

![Screenshot_20151206-223926.png](https://qiita-image-store.s3.amazonaws.com/0/86961/c40cd1c0-5300-c361-0b04-e6a78d9cefab.png)

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

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

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

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

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

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

そのために使うのが、

```java
mActivity.getWindowManager().getDefaultDisplay().getRotation()
```

で取得できる値です。

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

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

ということで、[githubに上げたもの](https://github.com/shimoda-tomoaki/CameraTestProject)では、カメラ映像がどれだけ回転しているかの判定をこのようにしています。

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

面倒でした！

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