티스토리 뷰

이전 포스팅에서 카메라 미리보기 예제에서는 Camera API와 SurfaceView를 이용하여 구현을 했었다. 

 

하지만, Camera API는 카메라가 지금의 스마트폰처럼 많지 않은 환경에 맞추어 개발된 API 이기 때문에 현재의 스마트폰 환경에 사용하기에는 적합하지 않아서 deprecated 되었다. 

 

카메라 미리보기와 SurfaceView에 대한 개념을 이해하기 위해서 다루고 넘어가긴 했으나, 특수한 경우가 아니라면 앞으로 작성할 일이 거의 없는 코드나 마찬가지이다. 

 

이번 포스팅에서 Camera API의 단점을 보완하고자 출시된 Camera2 API를 다뤄보면서 카메라 미리보기와 버튼을 눌러 파일을 저장하는 것까지 예제로 다뤄보려고 한다.


우선 Camera2 는 CameraManager instance를 사용하여 카메라를 제어한다.

 

각각의 CameraDevice는 CameraCharacteristics 라는 카메라 속성을 가지고 있는 Class를 가지고 있고, 이는 CameraManager.getCameraCharacteristics(String cameraId)를 통해 얻어온다.

 

byte stream이나 Image 객체를 가져오기 위해서는 CameraCaputureSession이라는 객체를 만들어야 한다. 

이는 void createCaptureSession(List, CameraCaptureSession.StateCallback, Handler) 메서드를 사용하여 호출한다.

 

이 때 Session 객체는 Surface객체와 연결되어 있으며, 각각의 Surface는 적당한 사이즈와 포맷으로 미리 설정되어 있어야 한다.

 

Preview는 보통 SurfaceTexture에 연결되며, still picture는 ImageReader, video는 MediaRecorder에 연결된다.

 

앱은 request builder를 통해 CaptureRequest를 만들고, active capture session 에게 전달되면 Image를 얻어오는 방식이다.

 

이제 코드를 한번 확인해보자.

 

◎activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        xmlns:app="http://schemas.android.com/apk/res-auto"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        tools:context=".MainActivity" android:orientation="vertical">

    <Button
            android:text="Button"
            android:layout_width="match_parent"
            android:layout_height="wrap_content" android:id="@+id/button"/>

    <TextureView
            android:layout_width="match_parent"
            android:layout_height="match_parent" android:id="@+id/textureView"/>
</LinearLayout>

 

레이아웃은 간단하게 버튼 하나와 textureView 하나로 구성해준다.

 

워낙 정의해준 콜백 함수가 많아 MainActivity 클래스가 길고 복잡한 느낌이 있지만, 코드를 끊어 올리는 것도 별로 안좋아하고.. 주석도 나름 잘 정리했다고 생각해서 그냥 올리도록 하겠다.

 

◎MainActivity.java

package com.example.samplecamera;

import android.content.Context;
import android.content.pm.PackageManager;
import android.graphics.Camera;
import android.graphics.ImageFormat;
import android.graphics.SurfaceTexture;
import android.hardware.camera2.*;
import android.hardware.camera2.params.StreamConfigurationMap;
import android.media.Image;
import android.media.ImageReader;
import android.os.Environment;
import android.os.Handler;
import android.os.HandlerThread;
import android.util.Log;
import android.util.Size;
import android.util.SparseIntArray;
import android.view.Surface;
import android.view.TextureView;
import android.view.View;
import android.widget.Button;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import androidx.core.app.ActivityCompat;
import com.yanzhenjie.permission.Action;
import com.yanzhenjie.permission.AndPermission;
import com.yanzhenjie.permission.runtime.Permission;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;

public class MainActivity extends AppCompatActivity {

    private TextureView textureView;
    private Button button;
    //화면 각도 상수
    private static final SparseIntArray ORIENTATIONS = new SparseIntArray();

    // 카메라 default 각도 변경 (가로 -> 세로)
    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);
    }

    // Camera2 Variables
    private String cameraId;
    private CameraDevice cameraDevice;
    private CameraCaptureSession cameraCaptureSession;
    private CaptureRequest.Builder captureReqBuilder;

    // variables need to save image
    private Size imageDimensions;
    private File file;
    private Handler mBackgroundHandler;
    private HandlerThread mBackgroundThread;

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

        textureView = (TextureView) findViewById(R.id.textureView);
        button = findViewById(R.id.button);

        AndPermission.with(this)
                .runtime()
                .permission(
                        Permission.CAMERA,
                        Permission.WRITE_EXTERNAL_STORAGE,
                        Permission.READ_EXTERNAL_STORAGE)
                .onGranted(new Action<List<String>>() {
                    @Override
                    public void onAction(List<String> permissions) {
                        startCamera();
                    }
                })
                .start();

    }

    // TextureView 에 Listener 지정
    private void startCamera() {
        textureView.setSurfaceTextureListener(textureListener);

        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                try{
                    takePicture();
                } catch (CameraAccessException e) {
                    e.printStackTrace();
                }
            }
        });
    }

    // textureListener 가 참조되었때 콜백 함수가 실행할 메서드
    private void openCamera() throws CameraAccessException {
        // getSystemService() 를 사용하여 CameraManager 객체 참조
        CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);

        /**
         *      Manager 객체를 이용하여 카메라 id 리스트를 얻어 온다.
         *      일반적으로 0번 : 후면 카메라, 1번 : 전면 카메라이다.
         *      얻은 cameraId를 바탕으로 해당 카메라의 정보를 가지는 CameraCharacteristics 객체를 반환하며,
         *      characteristics.get(characteristics.LENS_FACING) 으로 카메라 종류별 상수 값을 얻을 수 있다.
         *
         *      LENS_FACING_FRONT: 전면 카메라. value : 0
         *      LENS_FACING_BACK: 후면 카메라. value : 1
         *     LENS_FACING_EXTERNAL: 기타 카메라. value : 2
         * */
        cameraId = manager.getCameraIdList()[0];
        CameraCharacteristics characteristics = manager.getCameraCharacteristics(cameraId);

        // StreamConfigurationMap 객체는 각종 지원 정보가 포함되어 있다.
        StreamConfigurationMap map = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP);

        // 이미지 사이즈 반환
        imageDimensions = map.getOutputSizes(SurfaceTexture.class)[0];

        // Manager 가 Camera 를 open 할 때는 반드시 권한을 확인해야 한다.
        if (ActivityCompat.checkSelfPermission(this, android.Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
            AndPermission.with(this)
                    .runtime()
                    .permission(
                            Permission.CAMERA,
                            Permission.WRITE_EXTERNAL_STORAGE,
                            Permission.READ_EXTERNAL_STORAGE
                    )
                    .onGranted(new Action<List<String>>() {
                        @Override
                        public void onAction(List<String> permissions) {
                            startCamera();
                        }
                    })
                    .start();
            return;
        }
        // CameraID 와 Callback 객체를 지정한 뒤, 해당 카메라를 오픈
        manager.openCamera(cameraId, stateCallback, null);
    }

    // 파일 스트림 저장
    private void save(byte[] bytes) throws IOException {
        OutputStream outputStream = null;
        outputStream = new FileOutputStream(file);
        outputStream.write(bytes);
        outputStream.close();
    }
    
    // 미리보기 캡쳐 메서드
    private void takePicture() throws CameraAccessException {
        if(cameraDevice == null) return;

        // getSystemService() 를 사용하여 CameraManager 객체 참조
        CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);

        CameraCharacteristics characteristics = manager.getCameraCharacteristics(cameraDevice.getId());
        Size[] jpegSizes = null;

        //StreamConfigurationMap 객체에서 사진 사이즈 정보를 얻어 온다.
        jpegSizes = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP).getOutputSizes(ImageFormat.JPEG);

        int width = 640;
        int height = 480;

        if(jpegSizes != null && jpegSizes.length > 0) {
            width = jpegSizes[0].getWidth();
            height = jpegSizes[0].getHeight();
        }

        // 파일 저장을 위한 ImageReader
        ImageReader reader = ImageReader.newInstance(width, height, ImageFormat.JPEG, 1);

        // 카메라의 출력을 프리뷰, 사진, 동영상 등의 형태로 전송할 수 있는데, 각각의 출력을 surface 라고 한다.
        List<Surface> outputSurfaces = new ArrayList<>(2);

        // ImageReader 객체의 Surface 객체 삽입
        outputSurfaces.add(reader.getSurface());

        // 기존에 출력하고 있던 미리보기 화면의 SurfaceTexture
        outputSurfaces.add(new Surface(textureView.getSurfaceTexture()));

        // 리퀘스트 설정
        final CaptureRequest.Builder captureBuilder = cameraDevice.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));

        // 사진 이름 설정
        Long tsLong  = System.currentTimeMillis()/1000;
        String ts = tsLong.toString();

        // 파일 생성
        file = new File(Environment.getExternalStorageDirectory()+"/DCIM", "pic.jpg");

        // ImageReader 객체를 생성했을 때, 콜백되는 메서드
        ImageReader.OnImageAvailableListener readerListener = new ImageReader.OnImageAvailableListener() {
            @Override
            public void onImageAvailable(ImageReader imageReader) {
                Image image = null;
                // ImageReader 객체에 가장 마지막에 추가된 image 객체 참조
                image = reader.acquireLatestImage();
                ByteBuffer buffer = image.getPlanes()[0].getBuffer();
                byte[] bytes = new byte[buffer.capacity()];
                buffer.get(bytes);

                try{
                    save(bytes);
                } catch (IOException e) {
                    e.printStackTrace();
                } finally {
                    if(image != null) {
                        image.close();
                        reader.close();
                    }
                }
            }
        };

        // ImageReader 객체를 생성했을 때, 콜백되는 메서드
        reader.setOnImageAvailableListener(readerListener, mBackgroundHandler);

        // 이미지 캡쳐가 완료되었을 때, 다시 카메라 미리보기를 동작시키는 콜백 함수
        final CameraCaptureSession.CaptureCallback captureCallback = new CameraCaptureSession.CaptureCallback() {
            @Override
            public void onCaptureCompleted(@NonNull CameraCaptureSession session, @NonNull CaptureRequest request, @NonNull TotalCaptureResult result) {
                super.onCaptureCompleted(session, request, result);
                try{
                    createCameraPreview();
                } catch (CameraAccessException e) {
                    e.printStackTrace();
                }
            }
        };

        cameraDevice.createCaptureSession(outputSurfaces, new CameraCaptureSession.StateCallback() {
            @Override
            public void onConfigured(@NonNull CameraCaptureSession session) {
                try {
                    session.capture(captureBuilder.build(), captureCallback, mBackgroundHandler);
                } catch (CameraAccessException e) {
                    e.printStackTrace();
                }
            }

            @Override
            public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) {

            }
        }, mBackgroundHandler);

    }


    // 리스너 콜백 함수
    private TextureView.SurfaceTextureListener textureListener = new TextureView.SurfaceTextureListener() {
        // 이 리스너가 TextureView에 리스너로 지정되었을 때 실행될 콜백 함수
        @Override
        public void onSurfaceTextureAvailable(@NonNull SurfaceTexture surfaceTexture, int i, int i1) {
            try {
                openCamera();
            } catch (CameraAccessException e) {
                e.printStackTrace();
            }
        }

        @Override
        public void onSurfaceTextureSizeChanged(@NonNull SurfaceTexture surfaceTexture, int i, int i1) {

        }

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

        @Override
        public void onSurfaceTextureUpdated(@NonNull SurfaceTexture surfaceTexture) {

        }
    };


    // manager.openCamera() 메서드가 사용할 콜백
    private final CameraDevice.StateCallback stateCallback = new CameraDevice.StateCallback() {
        @Override
        public void onOpened(@NonNull CameraDevice camera) {
            //open 메서드가 받는 CameraDevice 인자를 맴버 인스턴스에 참조
            cameraDevice = camera;
            try {
                // TextureView 에 화면을 표시할 메서드
                createCameraPreview();
            } catch (CameraAccessException e) {
                e.printStackTrace();
            }
        }

        @Override
        public void onDisconnected(@NonNull CameraDevice camera) {
            cameraDevice.close();
        }

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

    // stateCallback 객체의 open 함수가 호출하는 메서드
    private void createCameraPreview() throws CameraAccessException {
        // TextureView 로 사용할 SurfaceTexture 객체을 참조한다.
        SurfaceTexture texture = textureView.getSurfaceTexture();

        // SurfaceTexture 객체의 버퍼 사이즈를 imageDimensions 로 초기화
        texture.setDefaultBufferSize(imageDimensions.getWidth(), imageDimensions.getHeight());

        // 해당 SurfaceTexture 객체를 Surface 객체로 감싸준다.
        Surface surface = new Surface(texture);

        // CameraDevice 객체에서 리퀘스트 빌더를 가져오고 Template 을 넣어준다.
        captureReqBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW);

        // 이미지를 넣어줄 타겟으로 surface 를 넣어준다.
        captureReqBuilder.addTarget(surface);

        /**
         *   카메라는 실시간으로 동작하는 기능이다.
         *   따로 실시간 동작하는 카메라 쓰레드에서 이미지를 받아올텐데, 그러한 흐림이 바로 세션이다.
         *   세선에 리퀘스트를 넣어주면 해당 리퀘스트의 기능을 수행하도록 할 수 있다.
         *
         *   인자 값으로, 이미지를 출력해줄 surface 넣고, 세션을 생성할 때  실행될 콜백을 넣어준다.
         * */
        cameraDevice.createCaptureSession(Arrays.asList(surface), new CameraCaptureSession.StateCallback() {
            @Override
            public void onConfigured(@NonNull CameraCaptureSession session) {
                if(cameraDevice == null) {
                    return;
                }
                cameraCaptureSession = session;

                try{
                    updatePreview();
                } catch (CameraAccessException e) {
                    e.printStackTrace();
                }
            }

            @Override
            public void onConfigureFailed(@NonNull CameraCaptureSession cameraCaptureSession) {
                Toast.makeText(getApplicationContext(), "Configuration Changed", Toast.LENGTH_LONG).show();
            }
        }, null);
    }

    // 카메라가 실행 준비가 되었을 때, onConfigured() 콜백 메서드가 실행되는데, 이 때 호출할 메서드이다.
    private void updatePreview() throws CameraAccessException {
        if(cameraDevice == null) {
            return;
        }

        // 리퀘스트 세팅: 빌드 전 카메라 기능에 대한 세팅
        captureReqBuilder.set(CaptureRequest.CONTROL_MODE, CameraMetadata.CONTROL_MODE_AUTO);

        // 세션 세팅
        cameraCaptureSession.setRepeatingRequest(captureReqBuilder.build(), null, mBackgroundHandler);
    }
    
    @Override
    protected void onResume() {
        super.onResume();
        startBackgroundThread();

        if (textureView.isAvailable()) {
            try {
                openCamera();
            } catch (CameraAccessException e) {
                e.printStackTrace();
            }
        } else {
            textureView.setSurfaceTextureListener(textureListener);
        }
    }
    
    @Override
    protected void onPause() {
        super.onPause();
        try {
            stopBackgroundThread();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    private void startBackgroundThread() {
        mBackgroundThread = new HandlerThread("Camera Background");
        mBackgroundThread.start();
        mBackgroundHandler = new Handler(mBackgroundThread.getLooper());
    }

    protected void stopBackgroundThread() throws InterruptedException {
        mBackgroundThread.quitSafely();
        mBackgroundThread.join();
        mBackgroundThread = null;
        mBackgroundHandler = null;
    }


}

Comments