vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

【Android】カメラ機能に触れてみる(Android5.0〜)

今回はAndroidのカメラ機能を使ってみました。

ら、ドハマりしたというお話です。


※2016.05.20更新

Android6.0用にコードを更新した記事を追加しました。


はじめに

Android5.0からカメラをコントロールするAPIが「android.hardware.camera2」に変わりました。
そこで、Android4.4以下の「android.hardware.camera」とAndroid5.0以上のActivityをそれぞれ作成して、APIのバージョンにあったActivityが起動するようにします。

今回は以下のサンプルを元に、Android5.0以上のコードについて書き留めておきます。

やったこと

  • ネットで拾ったサンプルを使って、Android端末のカメラを起動して、画像を保存する
  • 画面の回転に対応する

AndroidManifest

AndroidManifest.xml

< ?xml version="1.0" encoding="utf-8"? >
< manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="jp.cameratest" >
    < uses-permission android:name="android.permission.CAMERA" / >
    < uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" / >
    < uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" / >
    < uses-feature android:name="android.hardware.camera.autofocus" / >
    < uses-feature android:name="android.hardware.camera" / >
    < uses-feature android:name="android.hardware.camera2.full" / >
    < application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/AppTheme" >
        < activity
            android:name=".MainActivity"
            android:label="@string/app_name" >
            < intent-filter >
                < action android:name="android.intent.action.MAIN" / >
                < category android:name="android.intent.category.LAUNCHER" / >
            < /intent-filter >
        < /activity >
        < !-- Android5.0以上で使用 -- >
        < activity
            android:name=".Camera2Activity"
            android:label="@string/title_activity_camera2"
            android:configChanges="orientation|screenSize" >
        < /activity >
        < !-- Android4.4以下で使用 -- >
        < activity
            android:name=".CameraActivity"
            android:label="@string/title_activity_camera"
            android:configChanges="orientation|screenSize" >
        < /activity >
    < /application >
< /manifest >
  • 「uses-feature」を書いておくと、Google Playで未対応のデバイスを弾くことができるということなのですが、 今回のようにOSバージョンで「camera」か「camera2」のどちらかのみ対応している場合は両方書いても良いのでしょうか。
    もう少し調べてみたほうが良さそうです。 * 画面を回転させたときにイベントとして通知を受け取るために、Activityに「android:configChanges="orientation|screenSize"」を追加しています。
  • 今回はAndroid5.0以上に対応するため、「Camera2Activity」とこれを呼び出すための「MainActivity」を使用します。

MainActivity

サンプルによってはFragmentを使っている場合もあるようですが、前回まで扱っていたUnity用のプラグインとしても使えるように、ActivityでCameraを扱うこととします。
バージョン別のActivityは、一つにまとめてTargetAPIやif文で切り分けてもよいと思いますが、コード量が多くなりすぎる気がしたので別々のActivityにして、呼び出す部分で指定しています。

MainActivity.java

package jp.cameratest;

import android.app.Activity;
import android.content.Intent;
import android.os.Build;
import android.os.Bundle;
import android.os.StrictMode;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;

public class MainActivity extends Activity {
    private final static int SDKVER_LOLLIPOP = 21;
    private Button mBtnStart;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        // フルスクリーン表示.
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
        setContentView(R.layout.activity_main);
        mBtnStart = (Button)findViewById(R.id.btn_start);
        mBtnStart.setOnClickListener(mBtnStartClicked);
    }
    private final View.OnClickListener mBtnStartClicked = new View.OnClickListener(){
        @Override
        public void onClick(View v) {
          if (Build.VERSION.SDK_INT >= SDKVER_LOLLIPOP) {
              // Camera2を使ったActivityを開く.
              Intent ittMainView_Camera2 = new Intent(MainActivity.this, Camera2Activity.class);
              // 次画面のアクティビティ起動
              startActivity(ittMainView_Camera2);
          } else {
              // Cameraを使ったActivityを開く.
              Intent ittMainView_Camera = new Intent(MainActivity.this, CameraActivity.class);
              // 次画面のアクティビティ起動
              startActivity(ittMainView_Camera);
          }
        }
    };
    @Override
    protected void onResume() {
        super.onResume();
    }
    @Override
    protected void onPause() {
        super.onPause();
    }
}

カメラを起動してプレビューに表示する

載せておいてなんですが、長いですね〜。

メソッドの実行順は以下の通りです。

  1. onCreate : シャッターボタン、カメラのプレビューを表示するTextureViewにLayoutを紐付けています。
  2. onSurfaceTextureAvailable : TextureViewに関連付けられており、TextureViewが有効になったら実行されます。
  3. prepareCameraView : 2で実行しており、デバイスで使用するカメラを指定するなどの準備を行います。
  4. onOpened : カメラが起動したら実行され、5を実行します。
  5. updatePreview : 別スレッドを使ってTextureViewにカメラから取得した画像を表示します。

※なお、各メソッドに片っ端から@TargetAPIが付いているのはAndroid4.1〜Android4.4にも(別Activityで)対応したいからです。

Camera2Activity.java

package jp.cameratest;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import android.annotation.TargetApi;
import android.app.Activity;
import android.content.Context;
import android.content.res.Configuration;
import android.graphics.Matrix;
import android.graphics.Point;
import android.graphics.RectF;
import android.media.Image;
import android.media.ImageReader;
import android.media.MediaScannerConnection;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.os.Handler;
import android.os.HandlerThread;
import android.os.StrictMode;
import android.util.Log;
import android.util.Size;
import android.util.SparseIntArray;
import android.view.Display;
import android.view.Surface;
import android.view.TextureView;
import android.view.View;
import android.view.WindowManager;
import android.widget.Button;
import android.widget.Toast;
import android.graphics.ImageFormat;
import android.graphics.SurfaceTexture;
import android.hardware.camera2.CameraAccessException;
import android.hardware.camera2.CameraCaptureSession;
import android.hardware.camera2.CameraCharacteristics;
import android.hardware.camera2.CameraDevice;
import android.hardware.camera2.CameraManager;
import android.hardware.camera2.CameraMetadata;
import android.hardware.camera2.CaptureRequest;
import android.hardware.camera2.TotalCaptureResult;
import android.hardware.camera2.params.StreamConfigurationMap;

public class Camera2Activity extends Activity {

    private Size mPreviewSize;
    private TextureView mTextureView;
    private CameraDevice mCameraDevice;
    private CaptureRequest.Builder mPreviewBuilder;
    private CameraCaptureSession mPreviewSession;
    private Button mBtnTakingPhoto;

    private static final SparseIntArray ORIENTATIONS = new SparseIntArray();
    static {
        ORIENTATIONS.append(Surface.ROTATION_0, 90);
        ORIENTATIONS.append(Surface.ROTATION_90, 0);
        ORIENTATIONS.append(Surface.ROTATION_180, 270);
        ORIENTATIONS.append(Surface.ROTATION_270, 180);
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        StrictMode.ThreadPolicy policy = new StrictMode.ThreadPolicy.Builder()
                .detectAll()
                .penaltyLog()
                .build();
        StrictMode.setThreadPolicy(policy);

        // フルスクリーン表示.
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN);
        setContentView(R.layout.activity_camera);
        mTextureView = (TextureView)findViewById(R.id.camera2_view);
        mTextureView.setSurfaceTextureListener(mCameraViewStatusChanged);

        mBtnTakingPhoto = (Button)findViewById(R.id.btn_taking_photo);
        mBtnTakingPhoto.setOnClickListener(mBtnShotClicked);
    }
    private final TextureView.SurfaceTextureListener mCameraViewStatusChanged = new TextureView.SurfaceTextureListener(){
        @Override
        public void onSurfaceTextureAvailable(SurfaceTexture surface, int width, int height) {
            // Textureが有効化されたらカメラを初期化.
            prepareCameraView();
        }
        @Override
        public void onSurfaceTextureSizeChanged(SurfaceTexture surface, int width, int height) { }
        @Override
        public boolean onSurfaceTextureDestroyed(SurfaceTexture surface) {
            return false;
        }
        @Override
        public void onSurfaceTextureUpdated(SurfaceTexture surface) { }
    };
    private final View.OnClickListener mBtnShotClicked = new View.OnClickListener(){
        @Override
        public void onClick(View v) {
            takePicture();
        }
    };
    @TargetApi(21)
    private void prepareCameraView() {
        // Camera機能にアクセスするためのCameraManagerの取得.
        CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);
        try {
            // Back Cameraを取得してOpen.
            for (String strCameraId : manager.getCameraIdList()) {
                // Cameraから情報を取得するためのCharacteristics.
                CameraCharacteristics characteristics = manager.getCameraCharacteristics(strCameraId);
                if (characteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT) {
                    // Front Cameraならスキップ.
                    continue;
                }
                // ストリームの設定を取得(出力サイズを取得する).
                StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);
                // TODO: 配列から最大の組み合わせを取得する.
                mPreviewSize = map.getOutputSizes(SurfaceTexture.class)[0];

                // プレビュー画面のサイズ調整.
                this.configureTransform();

                manager.openCamera(strCameraId, new CameraDevice.StateCallback() {
                    @Override
                    public void onOpened(CameraDevice camera) {
                        mCameraDevice = camera;
                        createCameraPreviewSession();
                    }

                    @Override
                    public void onDisconnected(CameraDevice cmdCamera) {
                        cmdCamera.close();
                        mCameraDevice = null;
                    }

                    @Override
                    public void onError(CameraDevice cmdCamera, int error) {
                        cmdCamera.close();
                        mCameraDevice = null;
                    }
                }, null);
            }
        } catch (CameraAccessException e) {
            e.printStackTrace();
        }
    }
    @TargetApi(21)
    protected void createCameraPreviewSession() {
        if(null == mCameraDevice || !mTextureView.isAvailable() || null == mPreviewSize) {
            return;
        }
        SurfaceTexture texture = mTextureView.getSurfaceTexture();
        if(null == texture) {
            return;
        }
        texture.setDefaultBufferSize(mPreviewSize.getWidth(), mPreviewSize.getHeight());
        Surface surface = new Surface(texture);
        try {
            mPreviewBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);
        } catch (CameraAccessException e) {
            e.printStackTrace();
        }
        mPreviewBuilder.addTarget(surface);
        try {
            mCameraDevice.createCaptureSession(Arrays.asList(surface), new CameraCaptureSession.StateCallback() {

                @Override
                public void onConfigured(CameraCaptureSession session) {
                    mPreviewSession = session;
                    updatePreview();
                }

                @Override
                public void onConfigureFailed(CameraCaptureSession session) {

                    Toast.makeText(Camera2Activity.this, "onConfigureFailed", Toast.LENGTH_LONG).show();
                }
            }, null);
        } catch (CameraAccessException e) {
            e.printStackTrace();
        }
    }
    @TargetApi(21)
    protected void updatePreview() {
        if(null == mCameraDevice) {
            return;
        }
        // オートフォーカスモードに設定する.
        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 {
            // 画像を繰り返し取得してTextureViewに表示する.
            mPreviewSession.setRepeatingRequest(mPreviewBuilder.build(), null, backgroundHandler);
        } catch (CameraAccessException e) {
            e.printStackTrace();
        }
    }
〜省略〜

画像を保存する

Camera2Activity.java

※正直ここが一番良くわかっていません(何をしているかはわかっても、なぜそうしているのか、省略したり別のものに置き換えられないかがわからず。。。)。
また、画像を保存する前にFragmentを使って保存か撮り直しかを選択するよう変更するつもりですが、とりあえず現状のものを載せておきます。

〜省略〜
    @TargetApi(21)
    protected void takePicture() {
        if(null == mCameraDevice) {
            return;
        }
        CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);
        try {
            CameraCharacteristics characteristics = manager.getCameraCharacteristics(mCameraDevice.getId());

            Size[] jpegSizes = null;
            int width = 640;
            int height = 480;
            if (characteristics != null) {
                // デバイスがサポートしているストリーム設定からJPGの出力サイズを取得する.
                jpegSizes = characteristics
                        .get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP).getOutputSizes(ImageFormat.JPEG);
                if (jpegSizes != null && 0 < jpegSizes.length) {
                    width = jpegSizes[0].getWidth();
                    height = jpegSizes[0].getHeight();
                }
            }
            // 画像を取得するためのImageReaderの作成.
            ImageReader reader = ImageReader.newInstance(width, height, ImageFormat.JPEG, 1);
            List outputSurfaces = new ArrayList(2);
            outputSurfaces.add(reader.getSurface());
            outputSurfaces.add(new Surface(mTextureView.getSurfaceTexture()));

            final CaptureRequest.Builder captureBuilder = mCameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE);
            captureBuilder.addTarget(reader.getSurface());
            captureBuilder.set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO);

            // 画像を調整する.
            int rotation = getWindowManager().getDefaultDisplay().getRotation();
            captureBuilder.set(CaptureRequest.JPEG_ORIENTATION, ORIENTATIONS.get(rotation));

            // ファイルの保存先のディレクトリとファイル名.
            String strSaveDir = Environment.getExternalStorageDirectory().toString();
            String strSaveFileName = "pic_" + System.currentTimeMillis() +".jpg";

            final File file = new File(strSaveDir, strSaveFileName);

            // 別スレッドで画像の保存処理を実行.
            ImageReader.OnImageAvailableListener readerListener = new ImageReader.OnImageAvailableListener() {
                @Override
                public void onImageAvailable(ImageReader reader) {
                    Image image = null;
                    try {
                        image = reader.acquireLatestImage();

                        // TODO: Fragmentで取得した画像を表示.保存ボタンが押されたら画像の保存を実行する.

                        ByteBuffer buffer = image.getPlanes()[0].getBuffer();
                        byte[] bytes = new byte[buffer.capacity()];
                        buffer.get(bytes);
                        saveImage(bytes);
                    } catch (FileNotFoundException e) {
                        e.printStackTrace();
                    } catch (IOException e) {
                        e.printStackTrace();
                    } finally {
                        if (image != null) {
                            image.close();
                        }
                    }
                }
                private void saveImage(byte[] bytes) throws IOException {
                    OutputStream output = null;
                    try {
                        // 生成した画像を出力する.
                        output = new FileOutputStream(file);
                        output.write(bytes);
                    } finally {
                        if (null != output) {
                            output.close();
                        }
                    }
                }
            };
            // 別スレッドで実行.
            HandlerThread thread = new HandlerThread("CameraPicture");
            thread.start();
            final Handler backgroudHandler = new Handler(thread.getLooper());
            reader.setOnImageAvailableListener(readerListener, backgroudHandler);

            final CameraCaptureSession.CaptureCallback captureListener = new CameraCaptureSession.CaptureCallback() {
                @Override
                public void onCaptureCompleted(CameraCaptureSession session,
                                               CaptureRequest request, TotalCaptureResult result) {
                    // 画像の保存が終わったらToast表示.
                    super.onCaptureCompleted(session, request, result);
                    Toast.makeText(Camera2Activity.this, "Saved:"+file, Toast.LENGTH_SHORT).show();
                    // もう一度カメラのプレビュー表示を開始する.
                    createCameraPreviewSession();
                }
            };
            mCameraDevice.createCaptureSession(outputSurfaces, new CameraCaptureSession.StateCallback() {
                @Override
                public void onConfigured(CameraCaptureSession session) {
                    try {
                        session.capture(captureBuilder.build(), captureListener, backgroudHandler);
                    } catch (CameraAccessException e) {
                        e.printStackTrace();
                    }
                }
                @Override
                public void onConfigureFailed(CameraCaptureSession session) {

                }
            }, backgroudHandler);
            // 保存した画像を反映させる.
            String[] paths = {strSaveDir + "/" + strSaveFileName};
            String[] mimeTypes = {"image/jpeg"};
            MediaScannerConnection.scanFile(getApplicationContext(), paths, mimeTypes, mScanSavedFileCompleted);
        } catch (CameraAccessException e) {
            e.printStackTrace();
        }
    }
    private MediaScannerConnection.OnScanCompletedListener mScanSavedFileCompleted = new MediaScannerConnection.OnScanCompletedListener(){
        @Override
        public void onScanCompleted(String path,
                Uri uri){
            // このタイミングでToastを表示する?
        }
    };
    〜省略〜

画面の回転

ここまででカメラの基本的な機能は実行できるかと思います。
しかし、このまま画面を回転させると、Viewやボタンの表示位置自体は問題ないのものの表示される映像が傾いてしまいます。

で、TextureViewをそれに合わせて回転させると、TextureViewの大きさが画面いっぱいにならなくなります。

それを(無理やり)なんとかしてみたのがこちら。

Camera2Activity.java

@TargetApi(21)
public void onConfigurationChanged(Configuration newConfig)
{
    // 画面の回転・サイズ変更でプレビュー画像の向きを変更する.
    super.onConfigurationChanged(newConfig);
    this.configureTransform();
}
@TargetApi(21)
private void configureTransform()
{
    // 画面の回転に合わせてmTextureViewの向き、サイズを変更する.
    if (null == mTextureView || null == mPreviewSize)
    {
        return;
    }
    Display dsply = getWindowManager().getDefaultDisplay();

    int rotation = dsply.getRotation();
    Matrix matrix = new Matrix();

    Point pntDisplay = new Point();
    dsply.getSize(pntDisplay);

    RectF rctView = new RectF(0, 0, pntDisplay.x, pntDisplay.y);
    RectF rctPreview = new RectF(0, 0, mPreviewSize.getHeight(), mPreviewSize.getWidth());
    float centerX = rctView.centerX();
    float centerY = rctView.centerY();

    rctPreview.offset(centerX - rctPreview.centerX(), centerY - rctPreview.centerY());
    matrix.setRectToRect(rctView, rctPreview, Matrix.ScaleToFit.FILL);
    float scale = Math.max(
            (float) rctView.width() / mPreviewSize.getWidth(),
            (float) rctView.height() / mPreviewSize.getHeight()
    );
    matrix.postScale(scale, scale, centerX, centerY);

    switch (rotation) {
        case Surface.ROTATION_0:
            matrix.postRotate(0, centerX, centerY);
            break;
        case Surface.ROTATION_90:
            matrix.postRotate(270, centerX, centerY);
            break;
        case Surface.ROTATION_180:
            matrix.postRotate(180, centerX, centerY);
            break;
        case Surface.ROTATION_270:
            matrix.postRotate(90, centerX, centerY);
            break;
    }
    mTextureView.setTransform(matrix);
}
  • TextureViewの回転にはMatrixを使います。
  • Matrixでサイズ変更をするには、直接widthやheightを指定できないため、Scale値を指定します。
  • TextureViewをそのまま回転させると位置がずれてしまうため、画面のwidthやheightの中心点を指定して回転させます。
  • 先にも触れましたが、AndroidManifest.xmlに「android:configChanges="orientation|screenSize"」を加えて画面の回転時に「onConfigurationChanged」が呼ばれるようにします。
  • 現状の問題として、画面を縦にした時と横にした時でプレビューに映るものの大きさが違っている、ということが挙げられます。これも何とかしたいところです。

終わりに

簡単なことしかしていないはずなのに、書くことがずいぶんたくさんありますね。
もう少しシンプルにできないかと思ったりするのですが、皆様どうしているのでしょうか...。

画像を保存する前の確認画面、Android4.4以下向けのカメラ機能を作ったら、OpenCVを使った画像編集にも挑戦したいです。

参考

Camera2

ギャラリーに画像を反映する

ディスプレイサイズの取得

TextureView

画面回転