안드로이드 코틀린 : CameraX 카메라 앱 - Zoom In/Out 배율 확대 축소 조정하기
Lucy Archive
Lucy 2023
2021. 5. 22. 01:30

Android Kotlin : CameraX 카메라 앱 만들기 - 카메라 줌 배율 조절하기

지난 포스트에서는 CameraX 라이브러리로 제작한 기본 카메라 앱에 플래쉬를 ON/OFF 하는 방법에 대해 작성하였습니다. 이번 포스트는

Zoom 기능을 추가하는 방법

에 대해 소개합니다. 이 포스트는 CameraX 관련된 이전 포스트의 연장선상에서 작성하였습니다. CameraX 관련 포스트 리스트는 상단의 태그를 클릭하시거나, 하단의 관련 포스트 링크를 참조해주세요.

 

Zoom 확대/축소

카메라 줌 확대/축소를 위해 CameraControl, CameraInfo 인터페이스를 사용합니다. 이전 포스트에 CameraControl 와 CameraInfo를 간략히 소개하였으니, 참고해주세요.

CameraControl을 사용해서 CameraX 카메라앱 플래시 제어

 

안드로이드 코틀린 : CameraX, CameraControl - Flash(Torch) On/OFF

Android Kotlin : CameraX로 카메라 앱 만들기 - CameraController를 사용하여 플래시(Torch) 켜고 끄기 지난 포스트에서는 CameraX 라이브러리로 사진을 찍는 코드를 소개하였습니다. 이번 포스트는 CameraX 라..

juahnpop.tistory.com

 

배율 설정 하기 : CameraControl

CameraControl은 카메라가 실행되는 동안 카메라 줌을 조정하는 2개의 메서드를 지원합니다. setZoomRatio는 1배, 2배, 5배와 같은 배율로 카메라 줌을 설정하고, setLinearZoom은 0.0(최소) ~ 1.0(최대)사이 값의 선형적인 비율로 카메라 줌을 설정합니다.

  • setZoomRatio(float ratio)
    • ratio : 설정하고자 하는 배율
  • setLinearZoom(float linearZoom)
    • linearZoom : 0.0 과 1.0 사이의 값
// Obtain Camera, CameraControl Instance
val camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture)
val cameraController = camera.cameraControl

// setZoomRatio
cameraController.setZoomRatio(1F) // 1x Zoom
cameraController.setZoomRatio(2F) // 2x Zoom
cameraController.setZoomRatio(5F) // 2x Zoom

// setLinearZoom
cameraController.setLinearZoom(0F) // Mininum Zoom
cameraController.setLinearZoom(1F) // Maximum Zoom

 

지원하는 최대/최소 줌 알아보기 : CameraInfo

배율을 설정하기 위해 CameraControl의 2가지 메서드를 사용할 수 있습니다. CameraControl.setLinearZoom() 으로 배율을 설정하게 되면 0.0 ~ 1.0 사이의 값을 입력하면 되지만, CameraControl.setZoomRatio() 를 사용하는 경우 배율을 직접 입력해야 합니다. 여기서 문제가 하나 있습니다. 안드로이드 기기 종류마다 장착되는 카메라 모듈이 다르기 때문에, 지원되는 최대 줌 비율도 다릅니다. 앱이 실행되는 기기에서 지원하는 최대/최소 배율을 알고 싶은 경우 CameraInfo의 ZoomState 인터페이스를 사용 할 수 있습니다. 

ZoomState 인스턴스는 CameraInfo.getZoomState() 의 반환값입니다. 이 메서드의 반환 값은 LiveData<ZoomState> 입니다. ZoomState는 아래의 메세드를 지원하지만, 이 반환값은 LiveData이기 때문에 CameraInfo.ZoomState.getMaxZoomRatio() 와 같이 메서드를 바로 사용할 수 없습니다. LiveData 개체를 사용하기 위해 observe() 메서드를 사용하여 LiveData 객체에 Observer 객체를 연결해서 사용할 수 있습니다.

  • ZoomState Method
    • getLinearZoom() : 현재 줌 상태를 0 ~ 1범위로 반환
    • getZoomRatio() : 현재 줌 상태를 배율로 반환
    • getMaxZoomRatio() : 지원하는 최대 줌 배율 반환
    • getMinZoomRatio() : 지원하는 최소 줌 배율 반환
// Obtain Camera, CameraControl Instance
val camera = cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture)
val cameraInfo = camera.cameraInfo

// Get Info from LiveData<zoomState> 
lateinit var maxZoomRatio : Float
lateinit var minZoomRatio : Float

cameraInfo!!.zoomState.observe(this, androidx.lifecycle.Observer {
    maxZoomRatio = it.maxZoomRatio.toString()
    minZoomRatio = it.minZoomRatio.toString()
})

cameraInfo!!.zoomState.observe() 메서드를 Fragment 에서 사용하는 경우 this 대신에 viewLifecycleOwner 를 입력하면됩니다.

 

CameraX 카메라 앱에 적용하기

앱에 적용하기는 이전 포스트에서 코드를 추가한 것으로, 프로젝트 생성 및 환경 설정은 이전 포스트를 참고해주세요. 

레이아웃 : activity_main.xml

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">

    <Button
        android:id="@+id/btnTakePicture"
        android:layout_width="100dp"
        android:layout_height="100dp"
        android:layout_marginBottom="50dp"
        android:scaleType="fitCenter"
        android:text="Take Photo"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintBottom_toBottomOf="@id/viewFinder"
        android:elevation="2dp" />

    <androidx.camera.view.PreviewView
        android:id="@+id/viewFinder"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginBottom="8dp"
        app:layout_constraintBottom_toTopOf="@+id/btnTorch"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" >
    </androidx.camera.view.PreviewView>

    <Button
        android:id="@+id/btnTorch"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Torch On"
        android:layout_margin="5dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <Button
        android:id="@+id/btn1X"
        android:layout_width="60dp"
        android:layout_height="wrap_content"
        android:layout_margin="5dp"
        android:text="1x"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/btn2X" />

    <Button
        android:id="@+id/btn2X"
        android:layout_width="60dp"
        android:layout_height="wrap_content"
        android:layout_margin="5dp"
        android:text="2x"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/btn5X" />

    <Button
        android:id="@+id/btn5X"
        android:layout_width="60dp"
        android:layout_height="wrap_content"
        android:layout_margin="5dp"
        android:text="5x"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toStartOf="@+id/btn10X" />

    <Button
        android:id="@+id/btn10X"
        android:layout_width="60dp"
        android:layout_height="wrap_content"
        android:layout_margin="5dp"
        android:text="10x"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent" />

    <TextView
        android:id="@+id/txtZoomState"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello"
        android:textSize="20sp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"/>

    <TextView
        android:id="@+id/textView3"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_margin="20dp"
        android:text="CameraInfo"
        android:textSize="20sp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView

        android:id="@+id/textView4"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="MaxZoomRatio : "
        android:textSize="20sp"
        app:layout_constraintEnd_toStartOf="@+id/txtMaxZoom"
        app:layout_constraintTop_toTopOf="@+id/txtMaxZoom" />

    <TextView
        android:id="@+id/txtMaxZoom"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="20sp"
        app:layout_constraintEnd_toEndOf="@+id/textView3"
        app:layout_constraintTop_toBottomOf="@+id/textView3" />

    <TextView

        android:id="@+id/textView"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="MinZoomRatio : "
        android:textSize="20sp"
        app:layout_constraintEnd_toStartOf="@+id/txtMinZoom"
        app:layout_constraintTop_toTopOf="@+id/txtMinZoom" />

    <TextView
        android:id="@+id/txtMinZoom"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="20sp"
        app:layout_constraintEnd_toEndOf="@+id/txtMaxZoom"
        app:layout_constraintTop_toBottomOf="@+id/txtMaxZoom"/>
    
</androidx.constraintlayout.widget.ConstraintLayout>

 

코틀린 코드 : MainActivity.kt

MainActivity.kt 에 아래의 코드를 입력합니다. Zoom 확대/축소 관련 코드는 아래와 같습니다.

  • 30~32줄 : 카메라 인터페이스 클래스 내 전역 변수로 선언
  • 59~70줄 : 배율 버튼 클릭시 배율 조정하는 코드
  • 134~140줄 : 카메라 현재 줌 상태, 최대/최소 줌을 텍스트 뷰에 입력
package com.blacklog.camerax

import android.content.Intent
import android.net.Uri
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.core.content.ContextCompat
import com.blacklog.camerax.databinding.ActivityMainBinding
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import androidx.camera.core.Camera as Camera

class MainActivity : AppCompatActivity() {

    // ViewBinding
    lateinit private var binding : ActivityMainBinding
    private var preview : Preview? = null
    private var imageCapture: ImageCapture? = null
    private lateinit var outputDirectory: File
    private lateinit var cameraExecutor: ExecutorService

    // CameraController
    private var camera : Camera? = null
    private var cameraController : CameraControl? = null
    private var cameraInfo: CameraInfo? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        val view = binding.root
        setContentView(view)
        startCamera()
        binding.btnTakePicture.setOnClickListener {
            takePhoto()
        }
        outputDirectory = getOutputDirectory()
        cameraExecutor = Executors.newSingleThreadExecutor()

        binding.btnTorch.setOnClickListener {
            when(cameraInfo?.torchState?.value){
                TorchState.ON -> {
                    cameraController?.enableTorch(false)
                    binding.btnTorch.text = "Torch ON"
                }
                TorchState.OFF -> {
                    cameraController?.enableTorch(true)
                    binding.btnTorch.text = "Torch OFF"
                }
            }
        }

        binding.btn1X.setOnClickListener {
            cameraController?.setZoomRatio(1F)
        }
        binding.btn2X.setOnClickListener {
            cameraController?.setZoomRatio(2F)
        }
        binding.btn5X.setOnClickListener {
            cameraController?.setZoomRatio(5F)
        }
        binding.btn10X.setOnClickListener {
            cameraController?.setZoomRatio(8F)
        }
    }

    private fun takePhoto() {
        // Get a stable reference of the modifiable image capture use case
        val imageCapture = imageCapture ?: return
        // Create time-stamped output file to hold the image
        val photoFile = File(
                outputDirectory,
                newJpgFileName())
        // Create output options object which contains file + metadata
        val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
        // Set up image capture listener, which is triggered after photo has
        // been taken
        imageCapture.takePicture(
                outputOptions,
                ContextCompat.getMainExecutor(this),
                object : ImageCapture.OnImageSavedCallback {
                    override fun onError(exc: ImageCaptureException) {
                        Log.d("CameraX-Debug", "Photo capture failed: ${exc.message}", exc)
                    }
                    override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                        val savedUri = Uri.fromFile(photoFile)
                        val msg = "Photo capture succeeded: $savedUri"
                        Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT).show()
                        Log.d("CameraX-Debug", msg)

                        Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE).also{
                            it.data = savedUri
                            sendBroadcast(it)
                        }
                    }
                })
    }
    // viewFinder 설정 : Preview
    private fun startCamera() {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
        cameraProviderFuture.addListener({
            // Used to bind the lifecycle of cameras to the lifecycle owner
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
            // Preview
            preview = Preview.Builder()
                    .build()
                    .also {
                        it.setSurfaceProvider(binding.viewFinder.surfaceProvider)
                    }
            // ImageCapture
            imageCapture = ImageCapture.Builder()
                    .build()
            // Select back camera as a default
            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
            try {
                // Unbind use cases before rebinding
                cameraProvider.unbindAll()
                // Bind use cases to camera
                camera = cameraProvider.bindToLifecycle(
                        this,
                        cameraSelector,
                        preview,
                        imageCapture)

                cameraController = camera!!.cameraControl
                cameraInfo = camera!!.cameraInfo

                cameraInfo!!.zoomState.observe(this, androidx.lifecycle.Observer {
                    val currentZoomRatio = it.zoomRatio
                    Log.d("MyCameraXBasic", currentZoomRatio.toString())
                    binding.txtZoomState.text = currentZoomRatio.toString()
                    binding.txtMaxZoom.text = it.maxZoomRatio.toString()
                    binding.txtMinZoom.text = it.minZoomRatio.toString()
                })

            } catch(exc: Exception) {
                Log.d("CameraX-Debug", "Use case binding failed", exc)
            }
        }, ContextCompat.getMainExecutor(this))
    }

    private fun newJpgFileName() : String {
        val sdf = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.KOREA)
        val filename = sdf.format(System.currentTimeMillis())
        return "${filename}.jpg"
    }

    private fun getOutputDirectory(): File {
        val mediaDir = externalMediaDirs.firstOrNull()?.let {
            File(it, resources.getString(R.string.app_name)).apply {
                if(!this.exists()){
                    mkdirs()
                }
            }
        }
        return if (mediaDir != null && mediaDir.exists()) mediaDir
        else filesDir
    }

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

 

실행 결과

위 코드를 실행하면 아래와 같이 지원되는 카메라 배율과, 현재 배율이 표시되는 것을 확인 할 수 있고, 배율 버튼을 누르면 정상적으로 카메라 배율이 조정되는 것을 확인 할 수 있습니다. 참고로 아래 그림에 사용된 폰은 삼성 갤럭시 노트9입니다. 

코드 실행 결과

끝까지 읽어 주셔서 감사합니다.^^

관련포스트

🤞 안드로이드(코틀린) 앱 제작하기 관련글 목록 보기

🤞 안드로이드(코틀린) 카메라 관련글 목록 보기

🤞 안드로이드(코틀린) CameraX 라이브러리 관련글 목록 보기