[Kotlin][Android] Jetpack Compose로 CameraX로 객체 인식하기
안녕하세요.
오늘은 Jetpack Compose에서 CameraX를 다루는 방법과, ML Kit를 사용해서 실시간 객체 인식을 하는 방법에 대해 예제를 통해 알아보겠습니다.
목표
카메라로 실시간 영상을 보면서 화면 속 물체들을 자동으로 인식하고, 그 주변에 빨간 박스와 이름을 표시하는 앱을 만들어보겠습니다.
최종 결과물
- 실시간 카메라 프리뷰
- 자동 객체 인식 (사람, 자동차, 책 등)
- 인식된 객체 주변에 빨간 테두리 박스
- 객체 이름과 정확도 표시
기본 개념 이해하기
CameraX란?
안드로이드에서 카메라를 쉽게 사용할 수 있게 해주는 라이브러리
복잡한 카메라 API를 간단하게 만들어줌
다양한 안드로이드 기기에서 일관된 동작 보장
ML Kit이란?
구글에서 만든 머신러닝 라이브러리
별도의 AI 지식 없이도 객체 인식, 텍스트 인식 등을 쉽게 구현
기기에서 직접 동작 (인터넷 연결 불필요)
Jetpack Compose란?
안드로이드의 최신 UI 프레임워크
XML 대신 코틀린 코드로 UI 작성
1단계: 프로젝트 설정하기
1-1. 새 프로젝트 생성
Android Studio에서 아래처럼 새 프로젝트를 생성합니다.
- Empty Activitiy 선택 : Compose 의존성이 적용된 채로 Kotlin 프로젝트가 생성됩니다.
- Minimum SDK: API 21 이상 권장

1-2. 의존성 추가하기
app/build.gradle.kts 파일을 열고 다음을 추가합니다.
각 라이브러리의 버전은 최신 호환되는 버전을 확인하고 진행해주세요.
dependencies {
// CameraX - 카메라 기능을 위한 라이브러리들
implementation ("androidx.camera:camera-core:1.4.2") // 핵심 기능
implementation ("androidx.camera:camera-camera2:1.4.2") // Camera2 API 연결
implementation ("androidx.camera:camera-lifecycle:1.4.2") // 생명주기 관리
implementation ("androidx.camera:camera-view:1.4.2") // 프리뷰 화면
// ML Kit - 머신러닝 기능
implementation ("com.google.mlkit:object-detection:17.0.2") // 객체 인식
// Compose에서 권한 처리를 쉽게 해주는 라이브러리
implementation ("com.google.accompanist:accompanist-permissions:0.37.3")
}
1-3. 권한 설정하기
app/src/main/AndroidManifest.xml 파일에 카메라 권한을 추가합니다.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 카메라 사용 권한 - 반드시 필요 -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- 카메라 하드웨어가 있는 기기에서만 설치되도록 -->
<uses-feature
android:name="android.hardware.camera"
android:required="true" />
<application
... >
...
</application>
</manifest>
2단계: 카메라 프리뷰 만들기
이제 실제 카메라 화면을 보여주는 코드를 작성해봅시다.
2-1. 기본 카메라 프리뷰 Composable
새 파일 CameraPreview.kt를 만들고 다음 코드를 작성합니다.
import android.util.Log
import androidx.camera.core.CameraSelector
import androidx.camera.core.ExperimentalGetImage
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.resolutionselector.AspectRatioStrategy
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.objects.DetectedObject
import com.google.mlkit.vision.objects.ObjectDetection
import com.google.mlkit.vision.objects.ObjectDetector
import com.google.mlkit.vision.objects.defaults.ObjectDetectorOptions
@Composable
fun CameraPreview(
modifier: Modifier = Modifier,
onObjectsDetected: (List<DetectedObject>) -> Unit = {}, // ML Kit 객체 감지 결과 콜백
onPreviewResolutionUpdate: (Int, Int) -> Unit // 프리뷰/카메라 해상도 전달 콜백
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
// 해상도 업데이트가 한 번만 일어나도록 플래그
val resolutionUpdated = remember { mutableStateOf(false) }
// 카메라 프리뷰 설정 (화면에 보여지는 영상)
val preview = remember {
Preview.Builder()
.setResolutionSelector(
ResolutionSelector.Builder()
// 16:9 비율 우선 (불가하면 자동)
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
.build()
)
.build()
}
// 이미지 분석기 설정 (ML Kit에 전달할 영상)
val imageAnalyzer = remember {
ImageAnalysis.Builder()
.setResolutionSelector(
ResolutionSelector.Builder()
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
.setResolutionStrategy(
ResolutionStrategy(
android.util.Size(640, 480), // 최소 해상도 요청 (실제 값은 기기마다 다름)
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
)
)
.build()
)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) // 최신 프레임만 분석
.build()
}
// 후면 카메라 선택
val cameraSelector = remember {
CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_BACK)
.build()
}
// 카메라 바인딩 및 ML Kit 연결 (초기 1회 실행)
LaunchedEffect(Unit) {
// 카메라 제공자(실제 카메라 제어)
val cameraProvider = ProcessCameraProvider.getInstance(context).get()
// ML Kit 객체 감지기 생성 (실시간 스트림 모드, 분류 활성화)
val objectDetector = ObjectDetection.getClient(
ObjectDetectorOptions.Builder()
.setDetectorMode(ObjectDetectorOptions.STREAM_MODE)
.enableClassification()
.build()
)
// 이미지 분석기에 ML Kit 연결
imageAnalyzer.setAnalyzer(
ContextCompat.getMainExecutor(context) // 메인 스레드에서 실행
) { imageProxy ->
// 첫 번째 프레임에서 실제 카메라 해상도 전달 (한 번만)
if (!resolutionUpdated.value) {
val actualWidth = imageProxy.width
val actualHeight = imageProxy.height
Log.d("CameraPreview", "실제 카메라 해상도: ${actualWidth}x${actualHeight}")
onPreviewResolutionUpdate(actualWidth, actualHeight)
resolutionUpdated.value = true
}
// ML Kit으로 이미지 분석 시작
processImageProxy(imageProxy, objectDetector, onObjectsDetected)
}
try {
cameraProvider.unbindAll() // 기존 카메라 해제
// 카메라에 프리뷰와 이미지 분석기 연결 (실제 영상+분석)
cameraProvider.bindToLifecycle(
lifecycleOwner,
cameraSelector,
preview,
imageAnalyzer
)
} catch (e: Exception) {
Log.e("CameraPreview", "카메라 바인딩 실패: ${e.message}", e)
}
}
// 프리뷰를 실제로 보여주는 AndroidView (Compose에서 View 사용)
AndroidView(
factory = { context ->
PreviewView(context).apply {
// Preview 객체와 연결
preview.surfaceProvider = surfaceProvider
scaleType = PreviewView.ScaleType.FILL_CENTER // 화면에 꽉 차게 (비율 유지)
}
},
modifier = modifier.onGloballyPositioned { coordinates ->
// PreviewView의 실제 픽셀 크기 측정 (onGloballyPositioned는 Compose에서 실제 레이아웃 픽셀을 알 수 있는 콜백)
val width = coordinates.size.width
val height = coordinates.size.height
Log.d("PreviewView", "화면 크기: ${width}x${height}")
onPreviewResolutionUpdate(width, height) // 프리뷰 뷰의 크기 전달
}
)
}
/**
* 카메라 프레임을 ML Kit에 전달해서 객체를 감지하는 함수
*/
@androidx.annotation.OptIn(ExperimentalGetImage::class)
private fun processImageProxy(
imageProxy: ImageProxy, // 카메라에서 받은 영상
objectDetector: ObjectDetector, // ML Kit 객체 감지기
onObjectsDetected: (List<DetectedObject>) -> Unit // 감지 결과 콜백
) {
val mediaImage = imageProxy.image
if (mediaImage != null) {
// ML Kit에서 요구하는 형태로 변환 (회전 정보 포함)
val image = InputImage.fromMediaImage(
mediaImage,
imageProxy.imageInfo.rotationDegrees
)
// 객체 감지 시작 (비동기)
objectDetector.process(image)
.addOnSuccessListener { detectedObjects ->
// 객체 감지 성공 시 결과 콜백
Log.d("ObjectDetection", "감지된 객체 수: ${detectedObjects.size}")
onObjectsDetected(detectedObjects)
}
.addOnFailureListener { e ->
// 오류 발생 시 로그 출력
Log.e("ObjectDetection", "객체 감지 실패: ${e.message}", e)
}
.addOnCompleteListener {
// 성공/실패와 관계없이 반드시 close() 호출 (메모리 누수 방지)
imageProxy.close()
}
} else {
// 이미지가 null이어도 close()는 필수
imageProxy.close()
}
}
3단계: 감지 결과 표시하기
이제 ML Kit이 감지한 객체들을 화면에 표시해봅시다.
3-1. 오버레이 Composable 만들기
새 파일 ObjectDetectionOverlay.kt를 만들고 다음 코드를 작성합니다.
import android.graphics.Paint
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.unit.dp
import com.google.mlkit.vision.objects.DetectedObject
@Composable
fun ObjectDetectionOverlay(
detectedObjects: List<DetectedObject>, // ML Kit 감지 결과
previewWidth: Int, // 카메라 해상도(가로)
previewHeight: Int, // 카메라 해상도(세로)
modifier: Modifier = Modifier
) {
// 실제 화면(PreviewView)의 픽셀 크기 저장
val layoutInfo = remember { mutableStateOf(Pair(0, 0)) }
Box(
modifier = modifier.onGloballyPositioned { coordinates ->
// 화면(프리뷰) 실제 픽셀 크기 측정
layoutInfo.value = Pair(coordinates.size.width, coordinates.size.height)
}
) {
val (screenWidth, screenHeight) = layoutInfo.value
// Canvas로 사각형, 텍스트 등 자유롭게 그림
Canvas(modifier = modifier.fillMaxSize()) {
if (screenWidth == 0 || screenHeight == 0 || previewWidth == 0 || previewHeight == 0) return@Canvas
// --- [핵심] --- //
// Fit Center 방식으로 스케일 및 오프셋 계산
val scale = minOf(
screenWidth.toFloat() / previewWidth,
screenHeight.toFloat() / previewHeight
)
val offsetX = (screenWidth - previewWidth * scale) / 2f
val offsetY = (screenHeight - previewHeight * scale) / 2f
// 감지된 객체마다 반복
detectedObjects.forEach { detectedObject ->
val boundingBox = detectedObject.boundingBox
// 카메라 좌표 → 프리뷰 화면 좌표로 변환 (스케일+오프셋)
val left = boundingBox.left * scale + offsetX
val top = boundingBox.top * scale + offsetY
val right = boundingBox.right * scale + offsetX
val bottom = boundingBox.bottom * scale + offsetY
// 빨간 사각형 테두리 그리기
drawRect(
color = Color.Red,
topLeft = Offset(left, top),
size = Size(right - left, bottom - top),
style = Stroke(width = 4.dp.toPx()) // 두께 4dp
)
// 객체 이름/정확도 표시 (있다면)
detectedObject.labels.firstOrNull()?.let { label ->
val textPaint = Paint().apply {
color = Color.Red.toArgb()
textSize = 48f
isAntiAlias = true
}
val displayText = "${label.text} (${(label.confidence * 100).toInt()}%)"
// 사각형 위쪽에 텍스트 표시
drawContext.canvas.nativeCanvas.drawText(
displayText,
left,
top - 10f,
textPaint
)
}
}
}
}
}
3-2. 감지된 객체 정보 리스트 만들기
새 파일 DetectedObjects.kt 를 만들고, 아래와 같이 화면 하단에 감지된 객체들의 정보를 보여주는 리스트도 만들어봅시다.
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.google.mlkit.vision.objects.DetectedObject
@Composable
fun DetectedObjectsList(
detectedObjects: List<DetectedObject>, // 감지된 객체들
modifier: Modifier = Modifier
) {
// 감지된 객체 리스트를 세로 스크롤 가능한 LazyColumn으로 표시
LazyColumn(
modifier = modifier
.background(
Color.Black.copy(alpha = 0.7f), // 반투명 검은색 배경
RoundedCornerShape(12.dp) // 둥근 모서리
)
.padding(16.dp), // 내부 여백
verticalArrangement = Arrangement.spacedBy(8.dp) // 아이템 간 간격
) {
// 감지된 각 객체에 대해 아이템 생성
items(detectedObjects) { detectedObject ->
// 각 객체는 여러 개의 라벨(이름)을 가질 수 있음
detectedObject.labels.forEach { label ->
// 객체 정보 표시
ObjectInfoItem(
name = label.text, // 객체 이름
confidence = (label.confidence * 100).toInt() // 정확도 (퍼센트)
)
}
}
}
}
@Composable
private fun ObjectInfoItem(
name: String, // 객체 이름
confidence: Int // 정확도 (퍼센트)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween // 양쪽 끝으로 정렬
) {
Text(
text = name, // 객체 이름 (예: "사람")
color = Color.White, // 흰색 텍스트
fontSize = 16.sp
)
Text(
text = "$confidence%", // 정확도 (예: "85%")
color = Color.Green, // 초록색 텍스트
fontSize = 16.sp
)
}
}
4단계: 권한 처리하기
카메라를 사용하려면 사용자에게 권한을 요청해야 합니다.
4-1. 권한 요청 화면 만들기
새 파일 PermissionScreen.kt를 만들고 다음 코드를 작성합니다.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun PermissionDeniedScreen(
onRequestPermission: () -> Unit // 권한 요청 버튼을 눌렀을 때 호출될 함수
) {
// 화면 중앙에 내용 배치
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 제목
Text(
text = "카메라 권한이 필요합니다",
fontSize = 24.sp,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(16.dp))
// 설명 텍스트
Text(
text = "객체 인식 기능을 사용하려면\n카메라 접근 권한이 필요합니다.",
fontSize = 16.sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(32.dp))
// 권한 요청 버튼
Button(
onClick = onRequestPermission,
modifier = Modifier.fillMaxWidth()
) {
Text("권한 허용하기")
}
}
}
@Composable
fun LoadingScreen() {
// 권한 확인 중일 때 보여줄 화면
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
CircularProgressIndicator() // 로딩 스피너
Spacer(modifier = Modifier.height(16.dp))
Text("권한 확인 중...")
}
}
}
5단계: 메인 화면 완성하기
이제 모든 컴포넌트를 조합해서 완성된 화면을 만들어봅시다.
5-1. 메인 화면 Composable
새 파일 CameraMLScreen.kt를 만들고 다음 코드를 작성합니다.
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Card
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.cameraxtest.CameraPreview
import com.example.cameraxtest.DetectedObjectsList
import com.example.cameraxtest.LoadingScreen
import com.example.cameraxtest.ObjectDetectionOverlay
import com.example.cameraxtest.PermissionDeniedScreen
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.google.accompanist.permissions.shouldShowRationale
import com.google.mlkit.vision.objects.DetectedObject
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun CameraMLScreen() {
// 카메라 권한 상태 관리
val cameraPermissionState = rememberPermissionState(
android.Manifest.permission.CAMERA
)
// 감지된 객체들을 저장할 상태 변수
var detectedObjects by remember {
mutableStateOf<List<DetectedObject>>(emptyList())
}
// 최초 실행 시 권한 요청
LaunchedEffect(Unit) {
if (!cameraPermissionState.status.isGranted) {
cameraPermissionState.launchPermissionRequest()
}
}
// 권한 상태에 따라 다른 화면 표시
when {
// 권한이 허용된 경우 - 메인 카메라 화면 표시
cameraPermissionState.status.isGranted -> {
CameraScreen(
detectedObjects = detectedObjects,
onObjectsDetected = { objects ->
// ML Kit에서 객체를 감지할 때마다 호출됨
detectedObjects = objects
}
)
}
// 권한이 거부되었지만 다시 요청할 수 있는 경우
cameraPermissionState.status.shouldShowRationale -> {
PermissionDeniedScreen(
onRequestPermission = {
cameraPermissionState.launchPermissionRequest()
}
)
}
// 권한 확인 중인 경우
else -> {
LoadingScreen()
}
}
}
@Composable
private fun CameraScreen(
detectedObjects: List<DetectedObject>,
onObjectsDetected: (List<DetectedObject>) -> Unit
) {
// 전체 화면을 차지하는 Box 컨테이너
Box(modifier = Modifier.fillMaxSize()) {
// 프리뷰 해상도 상태
var previewWidth by remember { mutableStateOf(640) }
var previewHeight by remember { mutableStateOf(480) }
// 1. 카메라 프리뷰 (배경)
CameraPreview(
modifier = Modifier.fillMaxSize(),
onObjectsDetected = onObjectsDetected, // 객체 감지 결과를 상위로 전달
onPreviewResolutionUpdate = { width, height ->
previewWidth = width
previewHeight = height
}
)
// 2. 객체 감지 오버레이 (카메라 위에 그려짐)
ObjectDetectionOverlay(
detectedObjects = detectedObjects,
previewWidth = previewWidth,
previewHeight = previewHeight,
modifier = Modifier.fillMaxSize()
)
// 3. 감지된 객체 정보 리스트 (화면 하단)
if (detectedObjects.isNotEmpty()) {
DetectedObjectsList(
detectedObjects = detectedObjects,
modifier = Modifier
.align(Alignment.BottomStart) // 왼쪽 하단에 배치
.padding(16.dp)
.fillMaxWidth(0.8f) // 화면 너비의 80%만 사용
)
}
// 4. 상태 표시 (화면 상단)
StatusBar(
objectCount = detectedObjects.size,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(16.dp)
)
}
}
@Composable
private fun StatusBar(
objectCount: Int,
modifier: Modifier = Modifier
) {
// 상단에 현재 상태를 보여주는 바
Card(
modifier = modifier
) {
Text(
text = if (objectCount > 0) {
"감지된 객체: $objectCount 개"
} else {
"객체를 찾는 중..."
},
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
}
5단계: MainActivity 수정하기
마지막으로 MainActivity.kt를 수정해서 우리가 만든 화면을 표시합니다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.ui.Modifier
import com.example.cameraxtest.ui.theme.CameraMLScreen
import com.example.cameraxtest.ui.theme.CameraXtestTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 화면을 네비게이션/상태바까지 확장
enableEdgeToEdge()
// Compose UI 설정
setContent {
// 앱의 테마 적용
CameraXtestTheme { // 본인의 테마명으로 변경
// 배경 Surface: Material3에서 제공하는 기본 배경
Surface(
modifier = Modifier.fillMaxSize(), // 화면 전체를 채움
color = MaterialTheme.colorScheme.background // 테마의 배경색
) {
// 메인 화면(카메라+ML Kit) 표시
CameraMLScreen()
}
}
}
}
}