아직 알파 단계의 라이브러리 입니다

CameraX는 Jetpack에 포함된 서포트 라이브러리로 카메라 앱 개발을 쉽게 할 수 있도록 도와 줍니다.

특징

  • Camera2를 사용하므로 Android 5.0 (API level 21)까지만 지원
  • 유즈케이스 기반으로 설계되어 Preview, Image Processing, Image Capture 유즈 케이스 동시 지원
  • 생명주기를 인식함
  • 장치 호환성 문제 해결함으로 기기별 분기코드 감소
  • 특정 디바이스에 종속되는 Bokeh, HDR 등과 같은 이펙트 지원 

 

최소 요구 사항

CameraX 라이브러리를 사용하기 위해서는 다음과 같은 조건을 만족해야합니다.

  • Android API Level 21 (롤리팝 5.0)
  • 안드로이드 아키텍처 컴포넌트 1.1.1 버전
  • lifecycle-aware액티비티로는 FragmentActivity 또는  AppCompatActivity 사용

카메라 샘플 프로젝트 앱 만들어 보기

CameraX를 이용하여 미리보기, 사진찍기,이미지 프로세싱 3가지 유즈케이스를 포함하는 샘플 앱을 만들어 보도록 하겠습니다. 먼저 안드로이드 스튜디오를 열고 새로운 프로젝트를 만들어 주세요.

의존성 추가하기

CameraX라이브러리를 사용하기 위해서는 Google Maven 저장소를 프로젝트에 추가해야합니다.
프로젝트 레벨의 build.gradle을 열어 google() 리파지토리를 다음과 같이 추가합니다.

allprojects {
    repositories {
        google()
        jcenter()
    }
}

모듈레벨의 build.gradle에도 다음을 추가합니다.

dependencies {
    // CameraX 코어라이브러리를 추가합니다.
    def camerax_version = "1.0.0-alpha01"
    implementation "androidx.camera:camera-core:${camerax_version}"
    // Camera2 extensions을 사용하고 싶다면 아래내용도 추가해주세요
    implementation "androidx.camera:camera-camera2:${camerax_version}"

}

최신버전의 CameraX를 확인하시려면 링크를 클릭해주세요

미리보기 뷰 만들기(ViewFinder)

구글 공식 카메라X 예제에서는 ViewFinder(뷰파인더)라는 명칭을 쓰네요. 저도 뷰파인더라고 하겠습니다.
뷰파인더로 만들 View는 TextureView로 하겠습니다.

Note : TextureView를 사용하는 이유는 카메라로 부터 얻는 콘텐츠 스트림을 표현할 수 있는 적절한 뷰이기 때문입니다. Android에서는 이러한 스트림을 SurfaceTexture로 다룹니다. 좀 더 자세한 내용은 안드로이드 그래픽 시스템 및 카메라2 Basic 소스 분석 편을 참고해주세요

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
        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">

    <TextureView
            android:id="@+id/view_finder"
            android:layout_width="640px"
            android:layout_height="640px"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintEnd_toEndOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

권한 요청하기

안드로이드 시스템에서 카메라를 사용하기 위해서는 반드시 권한이 필요합니다. 매니페스트에 다음과 같이 선언해줍니다.

<uses-permission android:name="android.permission.CAMERA" />

MainActivity 클래스에 다음과 같은 상수를 선언합니다.

private const val REQUEST_CODE_PERMISSIONS = 10
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)

MainActivity 내부에 다음과 같은 필드와 메소드를 선언합니다. 카메라에 대한 권한이 요청되고 권한이 승인 되면  startCamera()를 호출하게 됩니다.

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
    }

    private lateinit var viewFinder: TextureView

    private fun startCamera() {
        ...
    }

    private fun updateTransform() {
        ...
    }

    /**
     * 권한이 승인 되면 카메라를 열고, 아니면 토스트를 띄우고 액티비티를 종료합니다
     */
    override fun onRequestPermissionsResult(
        requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
        if (requestCode == REQUEST_CODE_PERMISSIONS) {
            if (allPermissionsGranted()) {
                viewFinder.post { startCamera() }
            } else {
                Toast.makeText(this,
                    "Permissions not granted by the user.", 
                    Toast.LENGTH_SHORT).show()
                finish()
            }
        }
    }

    /**
     * 선언된 모든 권한을 체크 합니다.
     */
    private fun allPermissionsGranted(): Boolean {
        for (permission in REQUIRED_PERMISSIONS) {
            if (ContextCompat.checkSelfPermission(
                    this, permission) != PackageManager.PERMISSION_GRANTED) {
                return false
            }
        }
        return true
    }
}

MainActivity의 onCreate() 내부를 보도록하겠습니다. 

override fun onCreate(savedInstanceState: Bundle?) {

    viewFinder = findViewById(R.id.view_finder)

    // 카메라 권한 요청함
    if (allPermissionsGranted()) {
        viewFinder.post { startCamera() }
    } else {
        ActivityCompat.requestPermissions(
            this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
    }

    // 텍스쳐뷰가 변경될때마다 레이아웃을 새로 고칩니다.
    viewFinder.addOnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
        updateTransform()
    }
}

이제 앱을 시작하면 카메라 권한을 체크하게 됩니다. 권한이 이미 승인된적이 있다면 startCamera()를 직접적으로 호출하게 될것이고, 그렇지 않다면 권한을 요청하여 승인되었을 때 startCamera()를 호출합니다.

Note :  startCamera()를 메인쓰레드에서 호출하는 대신에 viewFinder.post {…}에서 호출했는데 이는 뷰파인더가 확실히 인플레이팅이 끝난담에 호출하기 위함입니다.

미리보기(뷰파인더) 구현하기

startCamera() 메소드 내에서 CameraX라이브러리를 통해 뷰파인더를 구현해 보도록 하겠습니다.

Note이 글을 쓰는 시점에서는 포함된 AppCompat 라이브러리에 FragmentActivity를 상속받았지만 LifecycleOwner가 구현되지 않은 경우 bindToLifecycle 메서드 호출시 오류가 발생할 수 있습니다. 그렇다면 기존 AppCompat 라이브러리의 종속성을 다음과 같이 변경해주세요.

implementation 'androidx.appcompat : appcompat : 1.1.0-alpha05'
private fun startCamera() {

    // 미리보기를 위한 환경설정 객체를 만듭니다.
    val previewConfig = PreviewConfig.Builder().apply {
        setTargetAspectRatio(Rational(1, 1))
        setTargetResolution(Size(640, 640))
    }.build()

    // 미리보기 객체를 만듭니다.
    val preview = Preview(previewConfig)

    // 뷰파인더가 갱신될때 마다 레이아웃을 다시 설정합니다.
    preview.setOnPreviewOutputUpdateListener {
        viewFinder.surfaceTexture = it.surfaceTexture
        // SurfaceTexture를 넘겨줌으로써 카메라영상이 뷰파인더에 연결됩니다.
        updateTransform()
    }

    // 생명주기에 카메라를 바이딩합니다
    CameraX.bindToLifecycle(this, preview)
}

이제 updateTransform()을 구현하여 기기의 회전방향에 따라 카메라 영상도 알맞게 매핑 될 수 있도록 합니다.

private fun updateTransform() {
    val matrix = Matrix()

    // 뷰파인더의 중심을 계산합니다.
    val centerX = viewFinder.width / 2f
    val centerY = viewFinder.height / 2f

    // 화면 회전을 위한 회전각도를 출력합니다
    val rotationDegrees = when(viewFinder.display.rotation) {
        Surface.ROTATION_0 -> 0
        Surface.ROTATION_90 -> 90
        Surface.ROTATION_180 -> 180
        Surface.ROTATION_270 -> 270
        else -> return
    }
    matrix.postRotate(-rotationDegrees.toFloat(), centerX, centerY)

    // 마침내 뷰파인더의 방향이 알맞게 나타납니다.
    viewFinder.setTransform(matrix)
}

앱을 이제 빌드하고 실행해보세요. 올바르게 미리보기가 나타나는 것을 확인할 수 있습니다.

사진 찍기 구현

activity_main.xml 에 다음과 같이 사진을 찍기 위한 버튼을 추가합니다.

<ImageButton
        android:id="@+id/capture_button"
        android:layout_width="72dp"
        android:layout_height="72dp"
        android:layout_margin="24dp"
        app:srcCompat="@android:drawable/ic_menu_camera"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

사진을 찍기위해 startCamera()메소드를 조금 수정해보도록 하겠습니다.

private fun startCamera() {
        //미리보기 설정 시작
        val previewConfig = ...
        val preview = ...
        //미리보기 설정 끝

        //사진찍기 설정 시작
        //사진을 찍기 위한 설정을 위해 ImageCaptureConfig를 생성합니다.
        val imageCaptureConfig = ImageCaptureConfig.Builder()
            .apply {
                setTargetAspectRatio(Rational(1, 1))
                //사진의 해상도를 직접 설정하지 않는 대신 화면 비율과 캡쳐모드로
                //CameraX라이브러리가 적당한 해상도를 고르게 합니다.
                setCaptureMode(ImageCapture.CaptureMode.MIN_LATENCY)
            }.build()
        // ImageCapture 객체를 이용해 버튼이 클릭되었을때 사진을 찍도록 합니다.
        val imageCapture = ImageCapture(imageCaptureConfig)
        findViewById<ImageButton>(R.id.capture_button).setOnClickListener {
            val file = File(externalMediaDirs.first(),
                "${System.currentTimeMillis()}.jpg")
            imageCapture.takePicture(file,
                object : ImageCapture.OnImageSavedListener {
                    override fun onError(error: ImageCapture.UseCaseError,
                                         message: String, exc: Throwable?) {
                        val msg = "Photo capture failed: $message"
                        Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                        exc?.printStackTrace()
                    }

                    override fun onImageSaved(file: File) {
                        val msg = "사진 경로 : ${file.absolutePath}"
                        Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                    }
                })
        }
        //사진찍기 설정 끝

        CameraX.bindToLifecycle(this, preview, imageCapture)
    }

다시 빌드 하고 실행하여 사진이 잘 찍히는지 확인해보세요.

이미지 프로세싱 구현하기

CameraX의 매우 흥미로운 기능은 ImageAnalysis 클래스입니다. 들어오는 카메라 프레임과 함께 호출 될 ImageAnalysis.Analyzer 인터페이스를 구현할 수 있습니다. 카메라 세션 상태를 관리하거나 이미지를 폐기하는 것에 대해 걱정할 필요가 없습니다. 

이 예제에서는 이미지의 휘도를 분석하는 커스텀 Analyzer를 만들어보도록 하겠습니다.

private class LuminosityAnalyzer : ImageAnalysis.Analyzer {
    private var lastAnalyzedTimestamp = 0L
    /**
     * 이미지 버퍼를 바이트 배열로 추출하기 위한 익스텐션
     */
    private fun ByteBuffer.toByteArray(): ByteArray {
        rewind()    // 버퍼의 포지션을 0으로 되돌림
        val data = ByteArray(remaining())
        get(data)   // 바이트 버퍼를 바이트 배열로 복사함
        return data // 바이트 배열 반환함
    }
    override fun analyze(image: ImageProxy, rotationDegrees: Int) {
        val currentTimestamp = System.currentTimeMillis()
        // 매프레임을 계산하진 않고 1초마다 한번씩 정도 계산
        if (currentTimestamp - lastAnalyzedTimestamp >= TimeUnit.SECONDS.toMillis(1)) {
            // 이미지 포맷이 YUV이므로 image.planes[0]으로 Y값을 구할수 있다.
            val buffer = image.planes[0].buffer
            // 이미지 데이터를 바이트배열로 추출
            val data:ByteArray = buffer.toByteArray()
            // 픽셀 하나하나를 유의미한 데이터리스트로 만든다
            val pixels:List<Int> = data.map { it.toInt() and 0xFF }
            // 이미지의 평균 휘도를 구한다
            val luma:Double = pixels.average()
            // 로그에 휘도 출력
            Log.d("CameraXApp", "Average luminosity: $luma")
            // 마지막 분석한 프레임의 타임스탬프로 업데이트한다.
            lastAnalyzedTimestamp = currentTimestamp
        }
    }
}

위와 같이 ImageAnaysis.Analyzer 인터페이스를 구현한 LuminosityAnlyzer를 만들었습니다. 이제 LuminosityAnalyzer를 인스턴스화 하여 startCamera()에 포함시키면 됩니다.

private fun startCamera() {
    //미리보기 설정
    val preview = ...
    //사진찍기 설정
    val imageCapture = ...
    
    //이미지 프로세싱 설정
    val analyzerConfig = ImageAnalysisConfig.Builder().apply {
        // 이미지 분석을 위한 쓰레드를 하나 생성합니다.
        val analyzerThread = HandlerThread("LuminosityAnalysis").apply { start() }
        setCallbackHandler(Handler(analyzerThread.looper))
        // 하나도 빠짐없이 프레임 전부를 분석하기보다는 매순간 가장 최근 프레임만을 가져와 분석하도록 합니다
        setImageReaderMode(ImageAnalysis.ImageReaderMode.ACQUIRE_LATEST_IMAGE)
    }.build()
    // 커스텀 이미지 프로세싱 객체 생성
    val analyzerUseCase = ImageAnalysis(analyzerConfig).apply {
        analyzer = LuminosityAnalyzer()
    }
    //유즈케이스들을 바인딩함
    CameraX.bindToLifecycle(this, preview, imageCapture, analyzerUseCase)
}

이제 다시 빌드 후 실행을 하면 로그에 휘도가 약 1초마다 찍히는것을 확인할 수 있습니다.

전체 소스코드는 github에서 확인 가능합니다.

카테고리: GraphicsKotlin

2개의 댓글

ㅋㅋ · 2020년 10월 20일 5:14 오후

안녕하세요! 게시물 잘 봤습니다. 코드를 활용해보려고 하는데 전면카메라 기능은 어떻게 해야하나요?

Charlezz · 2020년 10월 20일 6:25 오후

CameraX 라이브러리 Alpha 스테이지 초창기에 작성했던 글이라 공식문서를 참조하시기 바랍니다.

현재는 CameraSelector 빌드할 때 다음과 같이 전면 카메라를 선택하는 것 같습니다.
CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_FRONT)
.build()

답글 남기기

Avatar placeholder

이메일은 공개되지 않습니다. 필수 입력창은 * 로 표시되어 있습니다.