Android

[안드로이드]CameraX(Preview, ImageCapture)

나맘임 2023. 8. 6. 02:57

CameraX란?

개발자가 더 쉽게 카메라 기능을 구현할 수 있도록 만든 JetPack 라이브러리 

안드로이드 공식 문서에선 카메라 앱 제작 시 CameraX를 사용하도록 권고하고 있습니다.


주요 특징

1. 광범위한 기기 호환성

Android 5.0(API 25)까지 지원됩니다. 이는 기존 안드로이드 기기의 98% 이상입니다.

2. 사용 편의성

광범위한 기기의 호환성을 고려하며 CameraX에선 카메라에서 기본적으로 필요한 기능들을 기본적으로 제공합니다.

 

1) 미리보기(Preview)

카메라로 보여지는 화면을 앱 상에 띄우는 기능입니다.

 

2) 이미지 캡처(ImageCapture)

Preview로 가져온 이미지를 캡처하여 MediaStore에 저장하는 기능입니다.

 

3) 이미지 분석(ImageAnalysis)

이미지처리, 컴퓨터 비전 또는 머신러닝에서 사용할 수 있도록 CPU에서 액세스 가능한 이미지로 변환해 주는 기능입니다.

 

4) 동영상 캡처(ViedoCapture)

동영상 및 오디오를 저장하는 기능입니다.

3. 기기 간 일관성

다양한 기기에도 카메라 동작을 일관되게 작동하도록 유지시키는 기능을 가지고 있습니다.

4. 카메라 확장 API

CameraX엔 카메라 확장 API가 존재하는데 이는 기본 카메라 앱과 동일한 기능을 가져올 수 있습니다.

HDR, 야간 모드, 인물 촬영 모드등등 기본 카메라에 있는 기능이라면 그 기능을 사용할 수 있습니다.


1. Preview 구현

기본 레이아웃 구성

res/values/string.xml

<resources>
    <string name="app_name">My Application</string>
    <string name="take_photo">Take Photo</string>
    <string name="start_capture">Start Capture</string>
    <string name="stop_capture">Stop Capture</string>
</resources>

activity_main.xml

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

    <androidx.camera.view.PreviewView
        android:id="@+id/viewFinder"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintBottom_toTopOf="@id/lowerLayout"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_weight="4"/>

    <androidx.constraintlayout.widget.ConstraintLayout
        android:id="@+id/lowerLayout"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        app:layout_constraintTop_toBottomOf="@id/viewFinder"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintVertical_weight="1">


        <androidx.appcompat.widget.AppCompatButton
            android:id="@+id/image_capture_button"
            android:layout_width="0dp"
            android:layout_height="match_parent"
            android:text="@string/take_photo"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent"/>

    </androidx.constraintlayout.widget.ConstraintLayout>



</androidx.constraintlayout.widget.ConstraintLayout>

Preview 기능을 사용하기 위해선  PreviewView 라는 뷰를 사용해야 합니다.

 

해당 뷰의 id를 viewFinder라고 두었습니다.

 

카메라 권한 불러오기

AndroidManifest.xml

<uses-feature android:name="android.hardware.camera.any" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
   android:maxSdkVersion="28" />

CameraX는 사진 또는 동영상을 저장할 때 MediaStore를 사용합니다.

이에 MeidaStore의 외부 저장소 쓰기 권한이 필요합니다.

MainActivity.kt

private lateinit var viewBinding: ActivityMainBinding

private lateinit var cameraExecutor: ExecutorService

override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewBinding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)
        
        if (allPermissionsGranted()) {
        //차후 구현 예정
            startCamera()
        } else {
            ActivityCompat.requestPermissions(
                this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
        }
        cameraExecutor = Executors.newSingleThreadExecutor()
}

private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
       ContextCompat.checkSelfPermission(
           baseContext, it) == PackageManager.PERMISSION_GRANTED
}

override fun onRequestPermissionsResult(
   requestCode: Int, permissions: Array<String>, grantResults:
   IntArray) {
   if (requestCode == REQUEST_CODE_PERMISSIONS) {
       if (allPermissionsGranted()) {
       //차후 구현 예정
           startCamera()
       } else {
           Toast.makeText(this,
               "Permissions not granted by the user.",
               Toast.LENGTH_SHORT).show()
           finish()
       }
   }
}

override fun onDestroy() {
        super.onDestroy()
        cameraExecutor.shutdown()
}

//차후 카메라 구현에 필요한 const 및 권한 목록
companion object {
       private const val TAG = "CameraXApp"
       private const val FILENAME_FORMAT = "yyyy-MM-dd-HH-mm-ss-SSS"
       private const val REQUEST_CODE_PERMISSIONS = 10
       private val REQUIRED_PERMISSIONS =
           mutableListOf (
               Manifest.permission.CAMERA,
               Manifest.permission.RECORD_AUDIO
           ).apply {
               if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) {
                   add(Manifest.permission.WRITE_EXTERNAL_STORAGE)
               }
           }.toTypedArray()
}

앱이 실행될 때 사용자에게 카메라 사용 권한을 받아야 하기 때문에 사용자에게 권한을 요청합니다.

startCamera() 메소드 구현

MainActivity.kt

private fun startCamera() {
   val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

   cameraProviderFuture.addListener({
       val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

       val preview = Preview.Builder()
          .build()
          .also {
              it.setSurfaceProvider(viewBinding.viewFinder.surfaceProvider)
          }

       val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

       try {
           cameraProvider.unbindAll()

           cameraProvider.bindToLifecycle(
               this, cameraSelector, preview)

       } catch(exc: Exception) {
           Log.e(TAG, "Use case binding failed", exc)
       }

   }, ContextCompat.getMainExecutor(this))
}

preview를  PreviewViewPreviewView에 뛰우는 코드입니다. 

val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

먼저 ProcessCameraProvider를 통해 CameraProvider의 인스턴스를 가져옵니다.

 

   cameraProviderFuture.addListener({

   }, ContextCompat.getMainExecutor(this))

ProcessCameraProvider로 받은 인스턴스에 리스너를 추가하는데 이때 Runnable을 하나의 인수로 추가합니다.

 

ContextCompat.getMainExecutore()를 두 번째 인수로 추가하여 기븐 스레드에서 실행되는 Executor를 반환할 수 있게 합니다.

val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

차후 카메라의 수명 주기를 액티비티에 바인딩시키기 위해 Runnable 내에서 ProcesscameraProvider를 다시 가져옵니다.

 

val preview = Preview.Builder()
   .build()
   .also {
       it.setSurfaceProvider(viewBinding.viewFinder.surfaceProvider)
   }

Preview 객체를 초기화합니다. 이때 사용할 view를 지정합니다.

 

val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

기본 후면 카메라를 사용할 것이기 때문에 CameraSelector에서 DEFAULT_BACK_CAMERA를 가져옵니다.

 

여기서 전면 카메라를 사용하기 위해선

CameraSelector.DEFAULT_FRONT_CAMERA

를 사용해 주시면 됩니다.

 

try {
    cameraProvider.unbindAll()

    cameraProvider.bindToLifecycle(
        this, cameraSelector, preview)

} catch(exc: Exception) {
    Log.e(TAG, "Use case binding failed", exc)
}

 

마지막으로 카메라 수명 주기를 액티비티에 바인딩합니다.

 

카메라의 수명 주기를 액티비티에 바인딩하게 되기 때문에 

 

따로 카메라를 닫거나 여는 작업을 하지 않아도 됩니다.

 

결과물


2. ImageCapture 구현

MainActivity.kt

private lateinit var viewBinding: ActivityMainBinding

private var imageCapture: ImageCapture? = null

private lateinit var cameraExecutor: ExecutorService

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    viewBinding = ActivityMainBinding.inflate(layoutInflater)
    setContentView(viewBinding.root)

    if (allPermissionsGranted()) {
        startCamera()
    } else {
        ActivityCompat.requestPermissions(
            this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
    }
    viewBinding.imageCaptureButton.setOnClickListener { takePhoto() }

    cameraExecutor = Executors.newSingleThreadExecutor()
}

사진 촬영을 위해 버튼에 클릭 리스너를 추가해 줍니다. 또한 차후에 사용할 imageCapture 객체도 생성해 둡니다.

takePhoto() 메소드 구현

private fun takePhoto() {
    val imageCapture = imageCapture ?: return
    
    val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
        .format(System.currentTimeMillis())
    val contentValues = ContentValues().apply {
        put(MediaStore.MediaColumns.DISPLAY_NAME, name)
        put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
        if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
            put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image")
        }
    }
    
    val outputOptions = ImageCapture.OutputFileOptions
        .Builder(contentResolver,
            MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            contentValues)
        .build()
    
    imageCapture.takePicture(
        outputOptions,
        ContextCompat.getMainExecutor(this),
        object : ImageCapture.OnImageSavedCallback {
            override fun onError(exc: ImageCaptureException) {
                Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
            }

            override fun
                    onImageSaved(output: ImageCapture.OutputFileResults){
                val msg = "Photo capture succeeded: ${output.savedUri}"
                Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                Log.d(TAG, msg)
            }
        }
    )
}

 

사진 저장은 MediaStore를 통하여 진행되기 때문에 MediaStore에 저장될 수 있도록 미리 양식을 지정해둬야합니다.

 

val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
    .format(System.currentTimeMillis())
val contentValues = ContentValues().apply {
    put(MediaStore.MediaColumns.DISPLAY_NAME, name)
    put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
    if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
        put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image")
    }
}

name 변수엔 현재 시작을 파일 이름 포맷에 맞게 변환해서 저장해 두었고

 

contentValues는 MediaStore에 저장하기 위해서 필요한 칼럼의 key에 맞는 value를 저장해 둔 ContentValues입니다.

 

MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.MIME_TYPE, MediaStore.Images.Media.RELATIVE_PATH, 가 칼럼의 key라 보시면 됩니다.

 

val outputOptions = ImageCapture.OutputFileOptions
    .Builder(contentResolver,
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        contentValues)
    .build()

ImageCapture의 OutputFileOptions 객체를 사용해 출력 방법을 지정할 수 있으며,

MediaStore에 저장하기 때문에 contentResolver,

저장 위치인 MediaStore.Images.Media.EXTERNAL_CONTENT_URI,

위에서 저장한 contentValues를 지정합니다.

 

imageCapture.takePicture(
    outputOptions,
    ContextCompat.getMainExecutor(this),
    object : ImageCapture.OnImageSavedCallback {
        override fun onError(exc: ImageCaptureException) {
            Log.e(TAG, "Photo capture failed: ${exc.message}", exc)
        }

        override fun onImageSaved(output: ImageCapture.OutputFileResults){
            val msg = "Photo capture succeeded: ${output.savedUri}"
            Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
            Log.d(TAG, msg)
        }
    }
)

이미지 캡처를 실행하는 메소드로 캡처를 진행했을 때 결과를 받기 위한 콜백함수를 작성해야 합니다.

 

onError()는 캡처에 실패했을 경우, onImageSaved는 캡처에 성공했을 경우를 나타냅니다.

 

startCamera() 메소드 변경

private fun startCamera() {
    val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

    cameraProviderFuture.addListener({
        val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
        
        val preview = Preview.Builder()
            .build()
            .also {
                it.setSurfaceProvider(viewBinding.viewFinder.surfaceProvider)
            }

        imageCapture = ImageCapture.Builder()
            .build()
        
        val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

        try {
            cameraProvider.unbindAll()
            
            cameraProvider.bindToLifecycle(
                this, cameraSelector, preview, imageCapture)

        } catch(exc: Exception) {
            Log.e(TAG, "Use case binding failed", exc)
        }

    }, ContextCompat.getMainExecutor(this))
}

변경된 부분은 다음과 같습니다.

imageCapture = ImageCapture.Builder()
    .build()

ImageCapture 객체를 생성하는 부분입니다.

cameraProvider.bindToLifecycle(
    this, cameraSelector, preview, imageCapture)

카메라의 생명 주기를 바인딩할 때 생성한 ImageCapture 객체 또한 인수로 넘겨줍니다.

결과

가상 머신에서 카메라를 키면 도트 캐릭터가 나온다