💡 안드로이드 개발자가 되기 위해 공부하면서 적어가는 과정에서 잘못된 정보가 표기될 수 있습니다. 해당 부분은 댓글을 통해 지적해주시면 감사하겠습니다.
안드로이드 환경에서 OpenCV가 제공하는 카메라 API를 이용하여 카메라를 조작 할 수 있지만, 개발하기가 불편하고 사용자 입장에서도 느린 속도와 같은 이슈로 조작이 불편할 것으로 예상되기 때문에 CameraX를 활용하고자 CameraX에 관해 적어보고자 한다.
CameraX
는 Jetpack
라이브러리에서 제공하는 API이며 Camera2 API
를 기반으로 개발되었다. 안드로이드에서는 기존의 CameraAPI를 제공 했지만 서로 다른 제조사와 모델에서 동작 방식에 의해 결과물이 달라지는 어려움으로 인해 지원 중단 되고 현재로서는 Camera2 API로 지원되고 있으며, 카메라 애플리케이션을 만들기 위해 CameraX를 사용하길 권장하고 있다.
자동으로 다양한 기기에서 발생하는 차이를 고려하고 동일한 출력을 제공 해 주는 기가 간 일관성을 제공한다.
코드 작업에만 집중할 수 있도록 Use Case(사용 사례)를 기반으로 하는 적은 수의 코드로 일반적인 카메라 작업을 할 수 있도록 사용 편의성을 제공한다.
확장 라이브러리를 통해 추가적인 기능에 액세스할 수 있도록 확장 라이브러리 API를 통해 확장성을 제공한다.
<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" />
.any 코드를 추가하지 않으면 후면 카메라가 없는 기기에서는 코드가 동작하지 않는다
아래 코드를 app gradle에 추가한다.
dependencies {
// CameraX core library using the camera2 implementation
def camerax_version = "1.3.0-alpha04"
// The following line is optional, as the core library is included indirectly by camera-camera2
implementation "androidx.camera:camera-core:${camerax_version}"
implementation "androidx.camera:camera-camera2:${camerax_version}"
// If you want to additionally use the CameraX Lifecycle library
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
// If you want to additionally use the CameraX VideoCapture library
implementation "androidx.camera:camera-video:${camerax_version}"
// If you want to additionally use the CameraX View class
implementation "androidx.camera:camera-view:${camerax_version}"
// If you want to additionally add CameraX ML Kit Vision Integration
implementation "androidx.camera:camera-mlkit-vision:${camerax_version}"
// If you want to additionally use the CameraX Extensions library
implementation "androidx.camera:camera-extensions:${camerax_version}"
}
기본적으로 CameraX
로 카메라 앱을 개발하는 과정에는 사용자의 코드 작업 편의성을 위해 UseCase (사용사례)
추상 객체를 CameraController 또는 CameraProvider 카메라 모델 API에 바인딩하여 사용한다. Developer에서는 다음 단계로 설명하고 있다.
1. 원하는 Use Case와 구성 옵션을 지정
2. 리스너를 첨부하여 출력 데이터로 할 일 지정
3. Use Case를 Android 아키텍처 수명 주기에 바인딩하여
카메라 사용 시기 및 데이터 생성 시기를 언제 어디서 할 지 흐름 지정
CameraController
단일 클래스로 CameraX 핵심 기능을 제공한다. 설정 코드가 거의 필요하지 않는데, 카메라 초기화, 탭하여 초점 맞추기 등등을 자동으로 처리한다. 실제로 사용되는 클래스는 CameraController를 상속받아 제공되는 LifeCycleCameraController가 사용된다.
val previewView: PreviewView = viewBinding.previewView var cameraController = LifecycleCameraController(baseContext) cameraController.bindToLifecycle(this) cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA previewView.controller = cameraController
CameraProvider
상대적으로 더 많은 설정을 처리하므로 맞춤설정이 가능하다. 카메라 미리보기를 위해 CameraController는 PreviewView를 사용해야하지만, CameraProvider는 Surface를 이용하여 더 많은 유연성을 확보할 수 있다.
val preview = Preview.Builder().build() val viewFinder: PreviewView = findViewById(R.id.previewView) // The use case is bound to an Android Lifecycle with the following code val camera = cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, preview) // PreviewView creates a surface provider and is the recommended provider preview.setSurfaceProvider(viewFinder.getSurfaceProvider())
위에서 UseCase (사용사례)
를 사용한다고 적었는데 CameraX에서 사용되는 Use Case는 다음과 같다. 이전에는 3가지였는데 4가지로 업데이트 되었기 때문에 Use Case는 늘어날 수 있다.
Preview는 앱에 미리보기를 추가하기 위해 PreviewView를 사용한다. PreviewView
는 카메라가 활성화되면 촬영되는 이미지를 View의 내부 영역으로 스트리밍된다. Developer에서 설명하는 Preview
를 앱에 추가하는 단계는 다음과 같다.
- CameraController 혹은 CameraProvider를 사용하여 구성한다.
- PreviewView를 레이아웃에 추가한다.
- 첫 번째 단계에서 구성된 객체의 인스턴스를 요청한다.
- 카메라를 선택하고 수명 주기 및 Use Case를 바인딩한다.
아래 코드는 위 단계 중 카메라 선택 및 수명 주기와 Use Case의 바인딩 코드이다.
fun bindPreview(cameraProvider : ProcessCameraProvider) {
var preview : Preview = Preview.Builder()
.build()
var cameraSelector : CameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
preview.setSurfaceProvider(previewView.getSurfaceProvider())
var camera = cameraProvider.bindToLifecycle(this as LifecycleOwner, cameraSelector, preview)
}
또한, 추가적으로 CameraX는 이러한 PreviewView
의 크기와 미리보기의 크기가 다른 경우 배율 유형을 설정할 수 있도록 제공한다고 한다.
ImageCapture는 고해상도 고화질 사진을 촬영할 수 있도록 설계되었으며 자동 화이트 밸런스, 자동 노출, 자동 초점 기능과 함께 간단한 수동 카메라 컨트롤을 제공한다고 한다.
val imageCapture = ImageCapture.Builder()
.setTargetRotation(view.display.rotation)
.build()
cameraProvider.bindToLifecycle(lifecycleOwner, cameraSelector, imageCapture,
imageAnalysis, preview)
위 코드는 ImageCapture
Use Case를 빌드하고 cameraProvider
를 이용하여 라이프 사이클에 바인딩하는 코드이다. 그리고 다음 코드를 이용하여 사진을 찍을 수 있다.
fun onClick() {
val outputFileOptions = ImageCapture.OutputFileOptions.Builder(File(...)).build()
imageCapture.takePicture(outputFileOptions, cameraExecutor,
object : ImageCapture.OnImageSavedCallback {
override fun onError(error: ImageCaptureException)
{
// insert your code here.
}
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
// insert your code here.
}
})
}
또한, 이미지 캡처 설정에는 플래시, 연속 자동 초점, 제로 셔터 랙 등의 컨트롤을 제공한다.
ImageAnalysis는 이미지 처리, 컴퓨터 비전 또는 머신러닝 추론을 진행할 수 있도록 CPU에서 액세스 가능한 이미지를 앱에 제공한다. 그리고 애플리케이션의 분석 파이프라인이 CameraX의 프레임 속도 요구사항을 따라갈 수 없는 경우 프레임을 떨어트리는 방법으로 구성할 수도 있다.
이미지 분석 사용 사례를 사용하기 위해서는 ImageAnalysis.Analyzer
를 구현해야한다.
다음 코드는 Developer에서 미리 만들어서 공개하는 사진의 평균 광도를 기록하는 분석 도구이다.
private class LuminosityAnalyzer(private val listener: LumaListener) : ImageAnalysis.Analyzer {
private fun ByteBuffer.toByteArray(): ByteArray {
rewind() // Rewind the buffer to zero
val data = ByteArray(remaining())
get(data) // Copy the buffer into a byte array
return data // Return the byte array
}
override fun analyze(image: ImageProxy) {
val buffer = image.planes[0].buffer
val data = buffer.toByteArray()
val pixels = data.map { it.toInt() and 0xFF }
val luma = pixels.average()
listener(luma)
image.close()
}
}
해당 분석 도구를 사용하려면 코드를 추가하고 ImageAnalysis를 빌드한다.
val imageAnalyzer = ImageAnalysis.Builder()
.build()
.also {
it.setAnalyzer(cameraExecutor, LuminosityAnalyzer { luma ->
Log.d(TAG, "Average luminosity: $luma")
})
}
그리고 라이프사이클에 바인딩한다.
cameraProvider.bindToLifecycle(
this, cameraSelector, preview, imageCapture, imageAnalyzer)
실행하면 캡처되는 화면에 따라 광도가 기록된다.
앞서 나왔던 UseCase는 최종 버전이라고 하지만 VideoCapture는 현재 최종 버전이 아니며 추후 변경될 수 있다고 한다.
Developer에서는 일반적인 캡처 시스템을 아래 다이얼로그를 이용하여 동영상 및 오디오 스트림의 녹화 및 압축을 통한 데이터 생성 과정으로 설명하고 있다.
그러나 VideoCapture UseCase에서는 아래 방법으로 캡처 시스템이 동작되어 진다고 설명한다.
결론은 VideoCapture API는 복잡한 캡처 시스템을 추상화하고 애플리케이션에 훨씬 단순한 API를 제공한다고 설명하고 있다.
VideoCapture API를 사용하는 방법은 위처럼 먼저 Recorder
객체를 생성하고 VideoCapture
객체를 생성한다. VideoCapture
인스턴스를 생성하기 위해 Recorder
인스턴스가 필요하다. 그리고 생명 주기에 바인딩 한 다음 녹화를 시작하고 제어하는 과정을 통해 진행된다.
또한, QualitySelector
객체를 통해 Recorder
의 동영상 해상도를 구성할 수 있다.
아래 코드는 QualitySelector
를 만든 후 VideoCaptrue
를 이용하여 바인딩하는 코드이다.
val recorder = Recorder.Builder()
.setExecutor(cameraExecutor).setQualitySelector(qualitySelector)
.build()
val videoCapture = VideoCapture.withOutput(recorder)
try {
// Bind use cases to camera
cameraProvider.bindToLifecycle(
this, CameraSelector.DEFAULT_BACK_CAMERA, preview, videoCapture)
} catch(exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}
CameraX는 간편하고 빠른 API지만, preview + imageCapture + videoCapture + imageAnalysis의 조합은 지원하지 않기 때문에 원치 않는 결과가 발생할 수 있다. 실제로 해당 조합의 바인딩을 진행했을 때 화면이 회전하는 결과가 발생했다.
Developers
에서는 CameraX
를 이용하여 이미지 촬영 및 영상 촬영과 같은 작업을 하는 애플리케이션의 코드를 제공하고 있다. 이 코드를 기반으로 CameraX
를 활용하여 이미지 및 동영상을 캡처하는 애플리케이션을 만들어 보려고 한다.
Developer는 안드로이드 개발자 커뮤니티로서 안드로이드 개발을 하는데 있어서 중요한 정보를 제공한다.
CameraX
는 안드로이드 5.0 이상 버전을 요구한다. 프로젝트를 생성하면 종속성과 매니페스트에 권한을 추가해준다. 종속성과 권한을 추가하는 방법은 위의 CameraX
개요 설명에 있다.
만약 이미 프로젝트가 생성되어 있다면 해당 단계는 건너뛰면 된다.
나는 프로젝트를 새로 생성했기 때문에 MainActivity.kt
와 activity_main.xml
파일이 생성되었다. 우선 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="match_parent" />
<Button
android:id="@+id/image_capture_button"
android:layout_width="110dp"
android:layout_height="110dp"
android:layout_marginBottom="50dp"
android:layout_marginEnd="50dp"
android:elevation="2dp"
android:text="@string/take_photo"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintEnd_toStartOf="@id/vertical_centerline" />
<Button
android:id="@+id/video_capture_button"
android:layout_width="110dp"
android:layout_height="110dp"
android:layout_marginBottom="50dp"
android:layout_marginStart="50dp"
android:elevation="2dp"
android:text="@string/start_capture"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toEndOf="@id/vertical_centerline" />
<androidx.constraintlayout.widget.Guideline
android:id="@+id/vertical_centerline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent=".50" />
</androidx.constraintlayout.widget.ConstraintLayout>
추가로 String.xml
를 생성 또는 열기를 통해 resources
태그의 자식 태그들을 추가한다.
<resources>
<string name="app_name">CameraXApp</string>
<string name="take_photo">Take Photo</string>
<string name="start_capture">Start Capture</string>
<string name="stop_capture">Stop Capture</string>
</resources>
위에서 String.xml
의 수정이 끝나면, 아래 코드에서 필요한 부분을 본인 코드에 붙여넣고 수정하면 된다. 해당 코드는 ViewBinding
기반으로 뷰들을 바인딩하고, ImageCapture
와 VideoCapture
의 UseCase
를 사용하여 변수를 생성하고 필요한 권한을 요청하는 코드가 작성되어있다. 또한, 앞으로 작성할 함수의 기본틀과 상수값과 같은 변수들도 추가로 작성되어 있다.
import android.Manifest
import android.content.ContentValues
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.ImageCapture
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import android.widget.Toast
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.core.Preview
import androidx.camera.core.CameraSelector
import android.util.Log
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.camera.video.FallbackStrategy
import androidx.camera.video.MediaStoreOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.VideoRecordEvent
import androidx.core.content.PermissionChecker
import com.wnzyan.cameraxapp.databinding.ActivityMainBinding
import java.nio.ByteBuffer
import java.text.SimpleDateFormat
import java.util.Locale
typealias LumaListener = (luma: Double) -> Unit
class MainActivity : AppCompatActivity() {
private lateinit var viewBinding: ActivityMainBinding
private var imageCapture: ImageCapture? = null
private var videoCapture: VideoCapture<Recorder>? = null
private var recording: Recording? = null
private lateinit var cameraExecutor: ExecutorService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
// Request camera permissions
if (allPermissionsGranted()) {
startCamera()
} else {
ActivityCompat.requestPermissions(
this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
}
// Set up the listeners for take photo and video capture buttons
viewBinding.imageCaptureButton.setOnClickListener { takePhoto() }
viewBinding.videoCaptureButton.setOnClickListener { captureVideo() }
cameraExecutor = Executors.newSingleThreadExecutor()
}
private fun takePhoto() {}
private fun captureVideo() {}
private fun startCamera() {}
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(
baseContext, it) == PackageManager.PERMISSION_GRANTED
}
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
}
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()
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>, grantResults:
IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
startCamera()
} else {
Toast.makeText(this,
"Permissions not granted by the user.",
Toast.LENGTH_SHORT).show()
finish()
}
}
}
}
현재 상태에서 애플리케이션을 실행하면 다음과 같이 실행된다.
현재 애플리케이션 화면에는 아무런 동작하지 않는 검은 화면만 실행되고 있을 뿐이지만, ProcessCameraProvider
및 Preview
를 이용한 다음 코드를 추가하여 카메라를 실행하고 촬영되는 이미지를 화면에 표시할 수 있다.
private fun startCamera() {
// ProcessCameraProvider를 인스턴스를 비동기적으로 가져오기 위한 리스너를 받아온다.
// 카메라의 수명 주기를 수명 주기 소유자와 바인딩하는 데 사용된다.
// CameraX가 수명 주기를 인식하므로 카메라를 열고 닫는 작업이 필요하지 않게 된다.
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
// cameraProviderFuture에 리스너를 추가한다.
// 첫 번째 인수에는 Runnable를 넣는다.
// 두 번째는 기본 스레드에서 실행되는 Executor를 넣는다.
cameraProviderFuture.addListener(Runnable {
// Used to bind the lifecycle of cameras to the lifecycle owner
// ProcessCameraProvider 인스턴스를 동기적으로 받아온다.
// 카메라 수명 주기를 애플리케이션 프로세스 내의 LifecycleOwner에 바인딩하기 위해 사용된다.
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
// Preview 객체를 초기화 하고 뷰파인더에서 노출 영역 제공자를 가져온 다음 Preview에서 설정
val preview = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(viewBinding.viewFinder.surfaceProvider)
}
// Select back camera as a default
// 후면 카메라를 선택하는 객체를 생성
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
// cameraProvider에 바인딩된 항목이 없도록 한 다음
// 위에서 생성한 객체들을 cameraProvider에 바인딩
try {
// Unbind use cases before rebinding
cameraProvider.unbindAll()
// Bind use cases to camera
cameraProvider.bindToLifecycle(
this, cameraSelector, preview)
} catch(exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}
}, ContextCompat.getMainExecutor(this))
}
해당 코드에서 동작되는 큰 흐름은 startCamera
메서드를 수정하며, ProcessCameraProvider
의 인스턴스를 생성하고 Preview
를 생명 주기에 결합한다.
이 때 애플리케이션에서 실행되는 작업은 검은색이었던 화면에서 카메라에 촬영되는 이미지 미리보기가 보여지게 된다.
위의 단계 까지는 사진을 미리볼 수 있는 뷰를 구성하였고 TakePhoto 버튼을 통해 사진을 촬영하고 저장하는 단계를 구현하기 위해 ImageCapture
가 사용된 takePhoto()
메서드를 다음 코드로 수정한다.
private fun takePhoto() {
// Get a stable reference of the modifiable image capture use case
// ImageCapture UseCase에 대한 참조를 가져온다. 만약 초기화되지 않았다면 함수를 종료.
// UseCase는 이미지 캡처가 설정되기 전에 사진 버튼을 탭하면 null 된다.
val imageCapture = imageCapture ?: return
// Create time stamped name and MediaStore entry.
// MediaStore 콘텐츠 값을 만든다.
// MediaStore의 표시 이름이 고유하도록 현재 시간을 기준으로 타임스탬프를 사용한다.
val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
.format(System.currentTimeMillis())
// ContentValues를 사용하여 이미지에 대한 메타데이터 설정
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
// Android Q 이상에서는 RELATIVE_PATH를 사용하여 저장 경로 지정
if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image")
}
}
// Create output options object which contains file + metadata
// OutputFileOptions 객체를 생성.
// 이미지 저장 옵션 설정. MediaStore를 통해 이미지 저장 위치와 메타데이터 지정
// 이 객체에서 원하는 출력 방법을 지정할 수 있다.
val outputOptions = ImageCapture.OutputFileOptions
.Builder(contentResolver,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues)
.build()
// Set up image capture listener, which is triggered after photo has
// been taken
// takePicture()를 호출한다. 이미지 캡처 및 저장
// outputOptions, 실행자, 이미지가 저장될 때 콜백을 전달
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)
}
}
)
}
이미지를 저장할 때 원하는 옵션 설정을 통해 확장자 변경 및 메타데이터를 추가할 수 있다.
그리고 Preview
를 구현할 때 만들었던 startCamera()
메서드에서 다음 코드를 preview
인스턴스 생성 다음 코드에 추가한다.
// imageCaputre 인스턴스를 빌드
imageCapture = ImageCapture.Builder().build()
그리고 cameraProvider
의 라이프사이클 바인딩 코드를 수정한다.
cameraProvider.bindToLifecycle(this, cameraSelector, preview, imageCapture)
여기까지 진행된다면 실행 결과는 다음과 같다.
TakePhoto 버튼을 누르면 앱 화면에 촬영되었다는 토스트 메시지가 출력되고 실제로 갤러리에 들어가면 촬영된 이미지가 저장되어있다. 현재 이미지는 JPEG 포맷이지만, 촬영 포맷은 앞서 말했듯이 옵션을 추가하여 설정할 수 있다.
이렇게 구현된 카메라 애플리케이션은 사운드가 설정되어있지 않기 때문에 무음 카메라를 만들 수 있다. 사실 유독 한국에서 카메라 사운드에 예민한 경향이 있고, 미국과 유럽에서는 이러한 부분에서 굉장히 자유롭다.
동영상 촬영에는 영상 녹화와 음성 녹화를 통한 결과물의 인코딩 작업을 한 후 병합작업이 진행되는데 해당 작업은 VideoCapture
를 이용하여 간단하게 작성할 수 있다.
// Implements VideoCapture use case, including start and stop capturing.
private fun captureVideo() {
// 현재 VideoCapture 객체의 참조를 확인하거나 초기화되지 않았으면 함수를 종료한다.
val videoCapture = this.videoCapture ?: return
// CameraX에서 요청 작업을 완료할 때까지 UI 사용을 중지한다.
// VideoRecordListener에서 중복 녹화를 방지하기 위해 다시 설정 된다.
viewBinding.videoCaptureButton.isEnabled = false
val curRecording = recording
// 진행 중인 활성 녹화 세션이 있으면 중지하고 현재 recording 자원을 해제한다.
if (curRecording != null) {
// Stop the current recording session.
curRecording.stop()
recording = null
return
}
// create and start a new recording session
// 녹화를 시작하기 위해 비디오 녹화를 위한 파일 이름을 생성한다.
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, "video/mp4")
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/CameraX-Video")
}
}
// 콘텐츠의 외부 저장 위치를 옵션으로 설정하기 위해 빌더를 만들고 인스턴스를 빌드한다.
val mediaStoreOutputOptions = MediaStoreOutputOptions
.Builder(contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
.setContentValues(contentValues)
.build()
// 비디오 캡처 출력 옵션을 설정하고 녹화 영상 출력을 위한 세션을 만든다.
recording = videoCapture.output
.prepareRecording(this, mediaStoreOutputOptions)
.apply {
// 오디오를 사용 설정
if (PermissionChecker.checkSelfPermission(this@MainActivity,
Manifest.permission.RECORD_AUDIO) ==
PermissionChecker.PERMISSION_GRANTED)
{
withAudioEnabled()
}
}
.start(ContextCompat.getMainExecutor(this)) { recordEvent ->
// 새 녹음을 시작하고 리스너를 등록
when(recordEvent) {
// 요청 녹화를 시작하면 텍스트 전환
is VideoRecordEvent.Start -> {
viewBinding.videoCaptureButton.apply {
text = getString(R.string.stop_capture)
isEnabled = true
}
}
// 녹화가 완료되면 메시지를 등록하고 다시 텍스트 전환
is VideoRecordEvent.Finalize -> {
if (!recordEvent.hasError()) {
val msg = "Video capture succeeded: " +
"${recordEvent.outputResults.outputUri}"
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT)
.show()
Log.d(TAG, msg)
} else {
recording?.close()
recording = null
Log.e(TAG, "Video capture ends with error: " +
"${recordEvent.error}")
}
viewBinding.videoCaptureButton.apply {
text = getString(R.string.start_capture)
isEnabled = true
}
}
}
}
}
해당 코드로 takeVideo()
메서드를 수정하고 startCamera()
메서드에 ImageCapture
를 추가한 것 처럼 다음 코드를 추가해준다.
// VideoCapture UseCase를 생성한다.
val recorder = Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.HIGHEST,
FallbackStrategy.higherQualityOrLowerThan(Quality.SD)))
.build()
videoCapture = VideoCapture.withOutput(recorder)
cameraProvider.bindToLifecycle(
this, cameraSelector, preview, imageCapture, videoCapture)
import android.Manifest
import android.content.ContentValues
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.ImageCapture
import androidx.camera.video.Recorder
import androidx.camera.video.Recording
import androidx.camera.video.VideoCapture
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import android.widget.Toast
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.core.Preview
import androidx.camera.core.CameraSelector
import android.util.Log
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageCaptureException
import androidx.camera.core.ImageProxy
import androidx.camera.video.FallbackStrategy
import androidx.camera.video.MediaStoreOutputOptions
import androidx.camera.video.Quality
import androidx.camera.video.QualitySelector
import androidx.camera.video.VideoRecordEvent
import androidx.camera.view.LifecycleCameraController
import androidx.core.content.PermissionChecker
import com.wnzyan.cameraxapp.databinding.ActivityMainBinding
import java.nio.ByteBuffer
import java.text.SimpleDateFormat
import java.util.Locale
typealias LumaListener = (luma: Double) -> Unit
class MainActivity : AppCompatActivity() {
private lateinit var viewBinding: ActivityMainBinding
private var imageCapture: ImageCapture? = null
private var videoCapture: VideoCapture<Recorder>? = null
private var recording: Recording? = null
private lateinit var cameraExecutor: ExecutorService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewBinding = ActivityMainBinding.inflate(layoutInflater)
setContentView(viewBinding.root)
var cameraController = LifecycleCameraController(this)
// Request camera permissions
if (allPermissionsGranted()) {
startCamera()
} else {
ActivityCompat.requestPermissions(
this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
}
// Set up the listeners for take photo and video capture buttons
viewBinding.imageCaptureButton.setOnClickListener { takePhoto() }
viewBinding.videoCaptureButton.setOnClickListener { captureVideo() }
cameraExecutor = Executors.newSingleThreadExecutor()
}
private fun takePhoto() {
// Get a stable reference of the modifiable image capture use case
// ImageCapture UseCase에 대한 참조를 가져온다. 만약 초기화되지 않았다면 함수를 종료.
// UseCase는 이미지 캡처가 설정되기 전에 사진 버튼을 탭하면 null 된다.
val imageCapture = imageCapture ?: return
// Create time stamped name and MediaStore entry.
// MediaStore 콘텐츠 값을 만든다.
// MediaStore의 표시 이름이 고유하도록 현재 시간을 기준으로 타임스탬프를 사용한다.
val name = SimpleDateFormat(FILENAME_FORMAT, Locale.US)
.format(System.currentTimeMillis())
// ContentValues를 사용하여 이미지에 대한 메타데이터 설정
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, name)
put(MediaStore.MediaColumns.MIME_TYPE, "image/jpeg")
// Android Q 이상에서는 RELATIVE_PATH를 사용하여 저장 경로 지정
if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/CameraX-Image")
}
}
// Create output options object which contains file + metadata
// OutputFileOptions 객체를 생성.
// 이미지 저장 옵션 설정. MediaStore를 통해 이미지 저장 위치와 메타데이터 지정
// 이 객체에서 원하는 출력 방법을 지정할 수 있다.
val outputOptions = ImageCapture.OutputFileOptions
.Builder(contentResolver,
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues)
.build()
// Set up image capture listener, which is triggered after photo has
// been taken
// takePicture()를 호출한다. 이미지 캡처 및 저장
// outputOptions, 실행자, 이미지가 저장될 때 콜백을 전달
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)
}
}
)
}
// Implements VideoCapture use case, including start and stop capturing.
private fun captureVideo() {
// 현재 VideoCapture 객체의 참조를 확인하거나 초기화되지 않았으면 함수를 종료한다.
val videoCapture = this.videoCapture ?: return
// CameraX에서 요청 작업을 완료할 때까지 UI 사용을 중지한다.
// VideoRecordListener에서 중복 녹화를 방지하기 위해 다시 설정 된다.
viewBinding.videoCaptureButton.isEnabled = false
val curRecording = recording
// 진행 중인 활성 녹화 세션이 있으면 중지하고 현재 recording 자원을 해제한다.
if (curRecording != null) {
// Stop the current recording session.
curRecording.stop()
recording = null
return
}
// create and start a new recording session
// 녹화를 시작하기 위해 비디오 녹화를 위한 파일 이름을 생성한다.
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, "video/mp4")
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
put(MediaStore.Video.Media.RELATIVE_PATH, "Movies/CameraX-Video")
}
}
// 콘텐츠의 외부 저장 위치를 옵션으로 설정하기 위해 빌더를 만들고 인스턴스를 빌드한다.
val mediaStoreOutputOptions = MediaStoreOutputOptions
.Builder(contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI)
.setContentValues(contentValues)
.build()
// 비디오 캡처 출력 옵션을 설정하고 녹화 영상 출력을 위한 세션을 만든다.
recording = videoCapture.output
.prepareRecording(this, mediaStoreOutputOptions)
.apply {
// 오디오를 사용 설정
if (PermissionChecker.checkSelfPermission(this@MainActivity,
Manifest.permission.RECORD_AUDIO) ==
PermissionChecker.PERMISSION_GRANTED)
{
withAudioEnabled()
}
}
.start(ContextCompat.getMainExecutor(this)) { recordEvent ->
// 새 녹음을 시작하고 리스너를 등록
when(recordEvent) {
// 요청 녹화를 시작하면 텍스트 전환
is VideoRecordEvent.Start -> {
viewBinding.videoCaptureButton.apply {
text = getString(R.string.stop_capture)
isEnabled = true
}
}
// 녹화가 완료되면 메시지를 등록하고 다시 텍스트 전환
is VideoRecordEvent.Finalize -> {
if (!recordEvent.hasError()) {
val msg = "Video capture succeeded: " +
"${recordEvent.outputResults.outputUri}"
Toast.makeText(baseContext, msg, Toast.LENGTH_SHORT)
.show()
Log.d(TAG, msg)
} else {
recording?.close()
recording = null
Log.e(TAG, "Video capture ends with error: " +
"${recordEvent.error}")
}
viewBinding.videoCaptureButton.apply {
text = getString(R.string.start_capture)
isEnabled = true
}
}
}
}
}
private fun startCamera() {
// ProcessCameraProvider 인스턴스를 생성한다.
// 카메라의 수명 주기를 수명 주기 소유자와 바인딩하는 데 사용된다.
// CameraX가 수명 주기를 인식하므로 카메라를 열고 닫는 작업이 필요하지 않게 된다.
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
// cameraProviderFuture에 리스너를 추가한다.
// 첫 번째 인수에는 Runnable를 넣는다.
// 두 번째는 기본 스레드에서 실행되는 Executor를 넣는다.
cameraProviderFuture.addListener(Runnable {
// Used to bind the lifecycle of cameras to the lifecycle owner
// 카메라 수명 주기를 애플리케이션 프로세스 내의 LifecycleOwner에 바인딩한다.
val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()
// Preview 객체를 초기화 하고 뷰파인더에서 노출 영역 제공자를 가져온 다음 Preview에서 설정
val preview = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(viewBinding.viewFinder.surfaceProvider)
}
// imageCaputre 인스턴스를 빌드
imageCapture = ImageCapture.Builder().build()
// VideoCapture UseCase를 생성한다.
val recorder = Recorder.Builder()
.setQualitySelector(QualitySelector.from(Quality.HIGHEST,
FallbackStrategy.higherQualityOrLowerThan(Quality.SD)))
.build()
videoCapture = VideoCapture.withOutput(recorder)
// Select back camera as a default
// 후면 카메라를 선택하는 객체를 생성
val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
// cameraProvider에 바인딩된 항목이 없도록 한 다음
// 위에서 생성한 객체들을 cameraProvider에 바인딩
try {
// Unbind use cases before rebinding
cameraProvider.unbindAll()
// Bind use cases to camera
// Bind use cases to camera
cameraProvider.bindToLifecycle(
this, cameraSelector, preview, imageCapture, videoCapture)
} catch(exc: Exception) {
Log.e(TAG, "Use case binding failed", exc)
}
}, ContextCompat.getMainExecutor(this))
}
private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(
baseContext, it) == PackageManager.PERMISSION_GRANTED
}
override fun onDestroy() {
super.onDestroy()
cameraExecutor.shutdown()
}
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()
}
override fun onRequestPermissionsResult(
requestCode: Int, permissions: Array<String>, grantResults:
IntArray) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (allPermissionsGranted()) {
startCamera()
} else {
Toast.makeText(this,
"Permissions not granted by the user.",
Toast.LENGTH_SHORT).show()
finish()
}
}
}
private class LuminosityAnalyzer(private val listener: LumaListener) : ImageAnalysis.Analyzer {
private fun ByteBuffer.toByteArray(): ByteArray {
rewind() // Rewind the buffer to zero
val data = ByteArray(remaining())
get(data) // Copy the buffer into a byte array
return data // Return the byte array
}
override fun analyze(image: ImageProxy) {
val buffer = image.planes[0].buffer
val data = buffer.toByteArray()
val pixels = data.map { it.toInt() and 0xFF }
val luma = pixels.average()
listener(luma)
image.close()
}
}
}
위의 단계를 다 거치면 최종적으로 작성되는 코드들이다. CameraX
를 이용하여 간단하게 카메라를 제어하고 이미지 및 비디오를 촬영할 수 있는 방법을 알아보았다. 다음에는 CameraX
를 이용하여 이미지를 촬영한 후 OpenCV
를 활용하여 할 수 있는 컴퓨터 비전 기법을 공부할 예정이다.
와 정말 유익한 정보였어요!