Computer >> 컴퓨터 >  >> 시스템 >> Android

Android Camera2 API 완벽 가이드 – 사진과 동영상 촬영 구현 방법

스마트폰 카메라는 우리가 가장 자주 사용하는 기능 중 하나입니다. 실제로 많은 애플리케이션이 카메라를 핵심 기능으로 통합하고 있습니다.

카메라와 상호작용하는 방식에는 크게 두 가지가 있습니다. 하나는 표준적인 방법이고, 다른 하나는 카메라 기능을 직접 커스터마이징하는 방법입니다. 이 두 가지의 차이를 이해하는 것이 매우 중요하며, 바로 이 지점에서 Camera2가 등장합니다.

Camera2란 무엇인가?

Camera2 API는 API 레벨 21부터 제공되었지만, 개발자가 다루어야 할 아키텍처 중 가장 복잡한 부분에 속합니다.

이 API와 그 전신은 개발자가 애플리케이션 내부에서 카메라의 강력한 기능을 활용할 수 있도록 만들어졌습니다. 마이크나 볼륨을 제어하는 것과 마찬가지로, Camera2 API는 디바이스의 카메라와 상호작용할 수 있는 도구를 제공합니다.

일반적으로 Camera2 API를 사용하려는 경우는 단순히 사진 한 장을 찍거나 영상을 녹화하는 수준을 넘어설 때입니다. 이 API는 다양한 클래스를 노출하여 카메라를 깊이 있게 제어할 수 있게 해주며, 각 클래스는 특정 디바이스에 맞게 설정해야 하기 때문입니다.

이전에 카메라 API를 다뤄본 경험이 있더라도, Camera2는 기존 카메라 API와 너무나 달라서 알고 있던 내용을 잊는 편이 나을 정도입니다. 이 API의 사용법을 소개하는 자료는 많지만, 일부는 오래되었고 일부는 전체 그림을 보여주지 못합니다.

그래서 이 글에서는 빠진 부분을 스스로 찾아보지 않도록, Camera2 API와 상호작용하는 방법을 한 곳에서 모두 정리해 드리겠습니다.

Camera2 사용 사례

본격적으로 들어가기 전에 꼭 알아두어야 할 것이 있습니다. 단순히 사진을 찍거나 동영상을 녹화하는 것이 목적이라면 Camera2 API를 굳이 사용할 필요가 없습니다.

Camera2 API를 사용하는 주된 이유는 애플리케이션이 카메라 또는 그 기능과의 커스텀 상호작용을 필요로 하는 경우입니다.

단순 촬영만 원하신다면 Google에서 제공하는 아래 공식 문서를 참고하시길 권장합니다:

  1. 사진 촬영 (Take Photos)
  2. 동영상 촬영 (Capture Video)

해당 문서에는 훌륭한 사진과 영상을 촬영하기 위한 필수 단계가 모두 정리되어 있습니다. 다만 이 글의 초점은 Camera2 API를 직접 사용하는 방법에 맞춰 진행하겠습니다.

먼저 매니페스트 파일에 추가해야 할 항목들이 있습니다.

카메라 권한:

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

카메라 기능 선언:

<uses-feature android:name="android.hardware.camera" />

카메라 권한이 승인되었는지 확인하는 처리도 필요하지만, 이 주제는 이미 널리 다뤄졌으므로 이 글에서는 생략하겠습니다.

Camera2 API 컴포넌트 설정 방법

Camera2 API는 여러 새로운 인터페이스와 클래스를 도입했습니다. 각각을 하나씩 살펴보며 어떻게 활용하는지 이해해 보겠습니다.

Android Camera2 API 완벽 가이드 – 사진과 동영상 촬영 구현 방법
다양한 Camera2 컴포넌트들

먼저 TextureView부터 시작하겠습니다.

Camera2 TextureView 컴포넌트

TextureView는 콘텐츠 스트림(예: 영상)을 화면에 표시하기 위한 UI 컴포넌트입니다. 카메라 피드를 미리보기 형태로 보여주거나, 사진/동영상을 촬영하기 전 화면을 표시하려면 TextureView가 필요합니다.

TextureView에서 특히 중요하게 봐야 할 두 가지 속성은 다음과 같습니다:

  • SurfaceTexture 필드
  • SurfaceTextureListener 인터페이스

첫 번째는 콘텐츠가 표시되는 대상이고, 두 번째는 아래 네 가지 콜백을 포함합니다:

  1. onSurfaceTextureAvailable
  2. onSurfaceTextureSizeChanged
  3. onSurfaceTextureUpdated
  4. onSurfaceTextureDestroyed
private val surfaceTextureListener = object : TextureView.SurfaceTextureListener {
        override fun onSurfaceTextureAvailable(texture: SurfaceTexture, width: Int, height: Int) {

        }
        override fun onSurfaceTextureSizeChanged(texture: SurfaceTexture, width: Int, height: Int) {
        
        }
        
        override fun onSurfaceTextureDestroyed(texture: SurfaceTexture) {
           
        }
        override fun onSurfaceTextureUpdated(texture: SurfaceTexture) {
          
        }
}

카메라를 사용할 때 첫 번째 콜백이 결정적으로 중요합니다. SurfaceTexture가 사용 가능해지는 시점을 알림받아야 그 위에 카메라 피드를 표시할 수 있기 때문입니다.

주의할 점은 TextureView가 윈도우에 연결(attach)된 후에야 사용 가능 상태가 된다는 것입니다.

이전 API와 달리, 이제는 CameraManager를 통해 카메라와 상호작용합니다. CameraManager는 시스템 서비스로, CameraDevice 객체와의 상호작용을 가능하게 해줍니다.

특히 주목해야 할 메서드는 다음과 같습니다:

  • openCamera
  • getCameraCharacteristics
  • getCameraIdList

TextureView가 사용 가능하다는 것이 확인되면, openCamera를 호출하여 카메라와의 연결을 엽니다. 이 메서드는 세 개의 인자를 받습니다:

  1. CameraId – String 타입
  2. CameraDevice.StateCallback
  3. Handler

CameraId 인자는 어떤 카메라에 연결할지를 나타냅니다. 스마트폰에는 주로 전면 카메라와 후면 카메라 두 개가 있으며, 각각 고유한 id를 가집니다. 일반적으로 0 또는 1의 값입니다.

그렇다면 카메라 id는 어떻게 얻을까요? CameraManager의 getCameraIdList 메서드를 사용하면 됩니다. 이 메서드는 디바이스에서 식별된 모든 카메라 id를 문자열 배열로 반환합니다.

val cameraManager: CameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
val cameraIds: Array<String> = cameraManager.cameraIdList
var cameraId: String = ""
for (id in cameraIds) {
    val cameraCharacteristics = cameraManager.getCameraCharacteristics(id)
    //전면 카메라 대신 후면 카메라를 선택하고 싶은 경우
    if (cameraCharacteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT) 
      continue
    }
    
    val previewSize = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!.getOutputSizes(ImageFormat.JPEG).maxByOrNull { it.height * it.width }!!
    val imageReader = ImageReader.newInstance(previewSize.width, previewSize.height, ImageFormat.JPEG, 1)
    imageReader.setOnImageAvailableListener(onImageAvailableListener, backgroundHandler)
    cameraId = id
}

다음 인자들은 카메라 열기를 시도한 이후의 상태를 알려주는 콜백입니다. 생각해 보면 이 작업의 결과는 몇 가지 경우로 좁혀집니다:

  • 카메라가 성공적으로 열림
  • 카메라 연결이 끊김
  • 오류 발생

CameraDevice.StateCallback 내부에는 바로 이 세 가지가 담겨 있습니다:

 private val cameraStateCallback = object : CameraDevice.StateCallback() {
        override fun onOpened(camera: CameraDevice) {
           
        }

        override fun onDisconnected(cameraDevice: CameraDevice) {
           
        }

        override fun onError(cameraDevice: CameraDevice, error: Int) {
            val errorMsg = when(error) {
                ERROR_CAMERA_DEVICE -> "치명적 오류 (디바이스)"
                ERROR_CAMERA_DISABLED -> "디바이스 정책에 의해 비활성화"
                ERROR_CAMERA_IN_USE -> "카메라 사용 중"
                ERROR_CAMERA_SERVICE -> "치명적 오류 (서비스)"
                ERROR_MAX_CAMERAS_IN_USE -> "최대 카메라 사용 중"
                else -> "알 수 없는 오류"
            }
            Log.e(TAG, "카메라 연결 중 오류 발생 $errorMsg")
        }
    }

세 번째 인자는 이 작업이 어디에서 실행될지를 결정합니다. 메인 스레드를 점유하지 않는 것이 좋으므로, 백그라운드에서 처리하는 것이 바람직합니다.

그래서 Handler를 전달해야 합니다. 직접 선택한 스레드로 이 핸들러 인스턴스를 생성해 두면 작업을 위임할 수 있어 현명한 방법입니다.

private lateinit var backgroundHandlerThread: HandlerThread
private lateinit var backgroundHandler: Handler

 private fun startBackgroundThread() {
    backgroundHandlerThread = HandlerThread("CameraVideoThread")
    backgroundHandlerThread.start()
    backgroundHandler = Handler(
        backgroundHandlerThread.looper)
}

private fun stopBackgroundThread() {
    backgroundHandlerThread.quitSafely()
    backgroundHandlerThread.join()
}

여기까지 준비가 되었다면, 이제 openCamera를 호출할 수 있습니다:

cameraManager.openCamera(cameraId, cameraStateCallback,backgroundHandler)

그런 다음 onOpened 콜백 안에서, TextureView를 통해 사용자에게 카메라 피드를 표시하는 로직을 시작하면 됩니다.

Android Camera2 API 완벽 가이드 – 사진과 동영상 촬영 구현 방법
Markus Spiske / Unsplash

카메라 미리보기 화면 표시 방법

이제 카메라(cameraDevice)도 있고 피드를 보여줄 TextureView도 있습니다. 하지만 미리보기를 표시하려면 둘을 서로 연결해야 합니다.

이를 위해 TextureView의 SurfaceTexture 속성을 사용하고 CaptureRequest를 생성하겠습니다.

val surfaceTexture : SurfaceTexture? = textureView.surfaceTexture // 1

val cameraCharacteristics = cameraManager.getCameraCharacteristics(cameraId) //2
val previewSize = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!
  .getOutputSizes(ImageFormat.JPEG).maxByOrNull { it.height * it.width }!!

surfaceTexture?.setDefaultBufferSize(previewSize.width, previewSize.height) //3

val previewSurface: Surface = Surface(surfaceTexture)

captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW) //4
captureRequestBuilder.addTarget(previewSurface) //5

cameraDevice.createCaptureSession(listOf(previewSurface, imageReader.surface), captureStateCallback, null) //6
미리보기 생성 과정

위 코드에서 먼저 TextureView에서 surfaceTexture를 가져옵니다. 그다음 cameraCharacteristics 객체로 모든 출력 크기 목록을 조회하고, 원하는 크기를 surfaceTexture에 설정합니다.

이어서 TEMPLATE_PREVIEW를 전달하여 captureRequest를 생성하고, 입력으로 사용할 surface를 captureRequest에 추가합니다.

마지막으로 입력·출력 surface와 captureStateCallback을 전달해 captureSession을 시작하고, 핸들러에는 null을 넣습니다.

그렇다면 이 captureStateCallback은 무엇일까요? 글 초반의 다이어그램을 기억하신다면, 이것은 우리가 시작하는 CameraCaptureSession의 일부입니다. 이 객체는 다음 콜백들을 통해 captureRequest의 진행 상황을 추적합니다:

  • onConfigured
  • onConfigureFailed
private val captureStateCallback = object : CameraCaptureSession.StateCallback() {
        override fun onConfigureFailed(session: CameraCaptureSession) {
            
        }
        override fun onConfigured(session: CameraCaptureSession) {
         
        }
}

cameraCaptureSession이 성공적으로 구성되면, 세션에 반복 요청(repeating request)을 설정하여 미리보기를 계속 표시할 수 있게 합니다.

이를 위해 콜백에서 받은 session 객체를 사용합니다:

 session.setRepeatingRequest(captureRequestBuilder.build(), null, backgroundHandler)

첫 번째 인자로 앞서 만든 captureRequestBuilder 객체가 사용된다는 점을 눈여겨보세요. build 메서드를 호출함으로써 최종적으로 CaptureRequest가 전달됩니다.

두 번째 인자는 CameraCaptureSession.captureCallback 리스너입니다. 하지만 미리보기 용도이므로 캡처된 이미지로 특별한 작업을 할 필요가 없어 null을 전달합니다.

세 번째 인자는 핸들러이며, 여기서는 우리가 만든 backgroundHandler를 사용합니다. 반복 요청이 백그라운드 스레드에서 실행되기 때문에, 앞선 섹션에서 createCaptureSession의 핸들러에 null을 전달한 것입니다.

Android Camera2 API 완벽 가이드 – 사진과 동영상 촬영 구현 방법
Dicky Jiang / Unsplash

사진 촬영 방법

카메라의 실시간 미리보기만으로도 훌륭하지만, 대부분의 사용자는 이를 활용해 무언가를 하고 싶어 합니다. 사진을 찍기 위해 작성할 로직 중 일부는 앞선 섹션과 유사합니다.

  1. captureRequest를 생성합니다
  2. ImageReader와 그 리스너를 사용해 촬영된 사진을 수집합니다
  3. cameraCaptureSession을 통해 capture 메서드를 호출합니다
val orientations : SparseIntArray = SparseIntArray(4).apply {
    append(Surface.ROTATION_0, 0)
    append(Surface.ROTATION_90, 90)
    append(Surface.ROTATION_180, 180)
    append(Surface.ROTATION_270, 270)
}

val captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE)
captureRequestBuilder.addTarget(imageReader.surface)

val rotation = windowManager.defaultDisplay.rotation
captureRequestBuilder.set(CaptureRequest.JPEG_ORIENTATION, orientations.get(rotation))
cameraCaptureSession.capture(captureRequestBuilder.build(), captureCallback, null)
이번에는 TEMPLATE_STILL_CAPTURE로 캡처 요청을 생성합니다

그렇다면 ImageReader는 무엇일까요? ImageReader는 surface에 렌더링된 이미지 데이터에 접근할 수 있게 해주는 클래스입니다. 우리의 경우 그 대상은 TextureView의 surface입니다.

앞선 섹션의 코드를 살펴보면 이미 ImageReader를 정의한 부분이 있는 것을 알 수 있습니다.

val cameraManager: CameraManager = getSystemService(Context.CAMERA_SERVICE) as CameraManager
val cameraIds: Array<String> = cameraManager.cameraIdList
var cameraId: String = ""
for (id in cameraIds) {
    val cameraCharacteristics = cameraManager.getCameraCharacteristics(id)
    //전면 카메라 대신 후면 카메라를 선택하고 싶은 경우
    if (cameraCharacteristics.get(CameraCharacteristics.LENS_FACING) == CameraCharacteristics.LENS_FACING_FRONT) 
      continue
    }
    
    val previewSize = cameraCharacteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!.getOutputSizes(ImageFormat.JPEG).maxByOrNull { it.height * it.width }!!
    val imageReader = ImageReader.newInstance(previewSize.width, previewSize.height, ImageFormat.JPEG, 1)
    imageReader.setOnImageAvailableListener(onImageAvailableListener, backgroundHandler)
    cameraId = id
}
12~14번째 줄에 주목하세요

위 코드에서 볼 수 있듯이, ImageReader를 인스턴스화할 때는 너비와 높이, 원하는 이미지 포맷, 그리고 저장 가능한 이미지 개수를 전달합니다.

ImageReader 클래스에는 onImageAvailableListener라는 리스너 속성이 있습니다. 캡처 요청의 출력 소스로 그 surface를 전달했기 때문에, 사진이 촬영되는 순간 이 리스너가 트리거됩니다.

val onImageAvailableListener = object: ImageReader.OnImageAvailableListener{
        override fun onImageAvailable(reader: ImageReader) {
            val image: Image = reader.acquireLatestImage()
        }
    }

⚠️ 이미지를 처리한 후 반드시 close를 호출해야 합니다. 그렇지 않으면 다음 사진을 촬영할 수 없습니다.

Android Camera2 API 완벽 가이드 – 사진과 동영상 촬영 구현 방법
Jakob Owens / Unsplash

동영상 녹화 방법

동영상을 녹화하려면 MediaRecorder라는 새로운 객체와 상호작용해야 합니다. MediaRecorder는 오디오와 비디오를 녹화하는 역할을 담당하며, 우리는 이를 활용해 녹화 기능을 구현합니다.

무엇보다 먼저 MediaRecorder를 설정해야 합니다. 여러 가지 구성 항목이 있는데, 반드시 올바른 순서로 호출해야 하며 그렇지 않으면 예외가 발생합니다.

아래는 오디오 없이 비디오를 캡처할 수 있도록 해주는 구성 예시입니다.

fun setupMediaRecorder(width: Int, height: Int) {
  val mediaRecorder: MediaRecorder = MediaRecorder()
  mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE)
  mediaRecorder.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
  mediaRecorder.setVideoEncoder(MediaRecorder.VideoEncoder.H264)
  mediaRecorder.setVideoSize(videoSize.width, videoSize.height)
  mediaRecorder.setVideoFrameRate(30)
  mediaRecorder.setOutputFile(PATH_TO_FILE)
  mediaRecorder.setVideoEncodingBitRate(10_000_000)
  mediaRecorder.prepare()
}
setter 호출 순서가 매우 중요합니다

setOutputFile 메서드에 주의하세요. 이 메서드는 녹화된 영상을 저장할 파일 경로를 인자로 받습니다. 모든 구성을 마친 후에는 prepare를 호출해야 합니다.

MediaRecorder에는 start 메서드도 있으며, 반드시 prepare를 먼저 호출한 뒤에 start를 호출해야 합니다.

MediaRecorder 설정이 끝나면, 캡처 요청과 캡처 세션을 생성해야 합니다.

fun startRecording() {
        val surfaceTexture : SurfaceTexture? = textureView.surfaceTexture
        surfaceTexture?.setDefaultBufferSize(previewSize.width, previewSize.height)
        val previewSurface: Surface = Surface(surfaceTexture)
        val recordingSurface = mediaRecorder.surface
        captureRequestBuilder = cameraDevice.createCaptureRequest(CameraDevice.TEMPLATE_RECORD)
        captureRequestBuilder.addTarget(previewSurface)
        captureRequestBuilder.addTarget(recordingSurface)

        cameraDevice.createCaptureSession(listOf(previewSurface, recordingSurface), captureStateVideoCallback, backgroundHandler)
    }

미리보기 설정이나 사진 촬영과 마찬가지로, 입력 및 출력 surface를 정의해야 합니다.

여기서는 TextureView의 surfaceTexture로부터 Surface 객체를 만들고, MediaRecorder의 surface도 함께 가져옵니다. 캡처 요청을 생성할 때는 TEMPLATE_RECORD 값을 전달합니다.

captureStateVideoCallback은 사진 촬영 때 사용한 것과 같은 타입이며, 차이점은 onConfigured 콜백 안에서 MediaRecorder의 start 메서드를 호출한다는 것입니다.

val captureStateVideoCallback = object : CameraCaptureSession.StateCallback() {
      override fun onConfigureFailed(session: CameraCaptureSession) {
         
      }
      
      override fun onConfigured(session: CameraCaptureSession) {
          session.setRepeatingRequest(captureRequestBuilder.build(), null, backgroundHandler)
          mediaRecorder.start()
      }
  }
연속적인 영상을 캡처해야 하므로 여기서도 반복 요청을 설정합니다

이제 동영상 녹화가 시작되었습니다. 그렇다면 녹화는 어떻게 멈출까요? 이를 위해 MediaRecorder 객체의 stop과 reset 메서드를 사용합니다:

mediaRecorder.stop()
mediaRecorder.reset()

마무리

내용이 상당히 많았습니다. 여기까지 따라오셨다면 축하드립니다! 지름길은 없습니다. 코드로 직접 손을 더럽혀 보면서 비로소 모든 요소가 어떻게 연결되는지 이해하게 될 것입니다.

이 글에서 소개한 전체 코드는 아래 저장소에서 확인하실 수 있습니다:

TomerPacific의 GitHub 저장소(MediumArticles/Camrea2API)에는 Medium 아티클들과 관련된 다양한 코드가 담겨 있으니 참고해 보시기 바랍니다.

Android Camera2 API 완벽 가이드 – 사진과 동영상 촬영 구현 방법Android Camera2 API 완벽 가이드 – 사진과 동영상 촬영 구현 방법

마지막으로 기억하실 점은, 이 글의 내용은 Camera2 API의 빙산의 일각에 불과하다는 것입니다. 슬로우 모션 영상 촬영, 전면/후면 카메라 전환, 포커스 제어 등 훨씬 더 많은 기능을 구현할 수 있습니다.