최근 구글에서 On-Device용 AI 칩을 발표했다. 내가 AI 개발자는 아니지만 호기심이 생겨서 알아보다가 안드로이드에서 사용할 수 있는 ML Kit
라는 이름의 공식 AI 라이브러리가 있는 걸 발견했다. 오늘은 해당 라이브러리를 이용해 가볍게 사진에서 사람의 얼굴을 인식하는 예제를 구현해볼 예정이다.
ML Kit 라이브러리를 import 하는 방법은 2가지가 있다.
1. 번들로 필요한 모델을 빌드시간에 정적으로 연결하는 방법
2. Google Play 서비스를 통해 모델을 동적으로 다운로드하는 방법이다.
1번의 방법으로 하면 앱 용량이 더 커지지만 (약 6.9MB 정도) 대신 모델이 빌드 과정에서 모두 연결되기 때문에 빠르게 모델을 사용할 수 있다. 반면 2번 방법은 용량은 작아지지만 모델을 동적으로 다운받기 때문에 처음 모델을 이용할 때 다운받는 시간이 필요하다.
둘 중에 개발하고자 하는 앱에 더 맞는 방법을 택하면 될 것 같다.
모델을 앱과 번들로 묶는 방법
dependencies {
// ...
// Use this dependency to bundle the model with your app
implementation 'com.google.mlkit:face-detection:16.1.5'
}
Google Play 서비스에서 모델을 사용하는 경우
dependencies {
// ...
// Use this dependency to use the dynamically downloaded model in Google Play Services
implementation 'com.google.android.gms:play-services-mlkit-face-detection:17.1.0'
}
<application ...>
...
<meta-data
android:name="com.google.mlkit.vision.DEPENDENCIES"
android:value="face" >
<!-- To use multiple models: android:value="face,model2,model3" -->
</application>
버튼을 누르면 먼저 권한 체크를 한 뒤, 권한 확인 후 이미지를 가져오고 해당 이미지를 분석해 얼굴로 인식된 곳에 초록색 박스를 표시하는 동작을 구현할 것이다.
먼저 UI부터 구성한다.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainScreen()
}
}
}
@Composable
private fun MainScreen() {
val context = LocalContext.current
var imageBitmap by remember { mutableStateOf(ImageBitmap(32, 32)) }
Column(
modifier = Modifier.fillMaxSize()
) {
Button(
onClick = {
// TODO
}
) {
Text(text = "갤러리에서 불러오기")
}
Canvas(
modifier = Modifier.fillMaxSize()
) {
drawImage(
image = imageBitmap
)
// TODO (얼굴에 사각형 그리기)
}
}
}
이제 버튼을 눌렀을 때 로직을 구성한다. onClick()
로직은 다음과 같다.
1. 이미지 접근 권한 확인 (checkPermission)
2. 권한이 있을 경우 갤러리에서 이미지 불러오기
3. 권한이 없을 경우 권한 동의하기
먼저 갤러리 이미지 접근 권한을 확인하는 로직을 구성하자.
PermissionChecker.kt
파일을 따로 생성해 함수를 추가해준다. 예제에서는 이미지 접근 권한만 확인하면 되지만 어떤 권한 요청이 필요하더라고 확인할 수 있게 구현했다.
fun checkSinglePermission(
context: Context,
permission: String
): Boolean {
if (ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED) {
Log.d("test5", "권한이 이미 존재합니다.")
return true
}
return false
}
fun checkMultiplePermission(
context: Context,
permissions: List<String>
): Boolean {
permissions.forEach {
if (ContextCompat.checkSelfPermission(context, it) != PackageManager.PERMISSION_GRANTED)
return false
}
return true
}
그 다음 MainActivity
에 적용한다.
Button(
onClick = {
if (checkSinglePermission(context, Manifest.permission.READ_MEDIA_IMAGES)) {
// TODO (이미지 가져오기)
} else {
// TODO (권한 요청하기)
}
}
) {
Text(text = "갤러리에서 불러오기")
}
권한이 없을 때 사용자에게 권한을 요청하는 기능을 추가한다.
...
val requestPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
Log.d("test5", "권한이 동의되었습니다.")
} else {
Log.d("test5", "권한이 거부되었습니다.")
}
}
...
Button(
onClick = {
if (checkSinglePermission(context, Manifest.permission.READ_MEDIA_IMAGES)) {
// TODO (이미지 가져오기)
} else {
requestPermissionLauncher.launch(Manifest.permission.READ_MEDIA_IMAGES)
}
}
) {
Text(text = "갤러리에서 불러오기")
}
Compose에서 기존의 startActivityForResult
를 사용하려면 rememberLauncherForActivityResult
를 통해 Launcher
객체를 가져와야한다.
그런데 이 rememberLauncherForActivityResult
는 Composable
함수라서 외부로 빼기가 쉽지 않다. UI 레이어와 비즈니스 로직을 분리하고 싶은데 유독 이 rememberLauncherForActivityResult
를 사용할 때는 잘 안된다. 나중에 이 부분도 한번 찾아봐야겠다.
아무튼 이제 갤러리에서 이미지를 불러오는 과정만 진행하면 된다.
...
val pickGalleryLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.PickVisualMedia()
) {
if (it == null)
throw NullPointerException()
val source = ImageDecoder.createSource(context.contentResolver, it)
imageBitmap = ImageDecoder.decodeBitmap(source) { decoder, _, _ ->
decoder.setTargetSize(1080, 1080)
}.asImageBitmap()
}
...
Button(
onClick = {
if (checkSinglePermission(context, Manifest.permission.READ_MEDIA_IMAGES)) {
pickGalleryLauncher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly))
} else {
requestPermissionLauncher.launch(Manifest.permission.READ_MEDIA_IMAGES)
}
}
) {
Text(text = "갤러리에서 불러오기")
}
먼저 이미지를 처리하고 얼굴을 인식해 줄 ImageProcessor
를 구현한다.
object ImageProcessor {
private val highAccuracyOpts = FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_ALL)
.build()
private val detector = FaceDetection.getClient(highAccuracyOpts)
suspend fun processInputImage(image: InputImage): List<Face> {
return suspendCoroutine { continuation ->
detector.process(image).addOnSuccessListener { faces ->
continuation.resume(faces)
}
.addOnFailureListener {
throw it
}
}
}
}
FaceDetection.getClient()
메소드를 통해 FaceDetector
객체를 가져올 수 있는데, 이 때 FaceDetectorOptions
객체를 이용해 설정을 지정할 수 있다.
위 예제에서는 PERFORMANCE_MODE_ACCURATE
, LANMARK_MODE_ALL
, CLASSIFICATION_MODE_ALL
을 사용했는데 각각 정확도 위주, 얼굴의 표지점 표시(눈, 코, 입 등), 카테고리 분류(웃음, 눈 뜸 등)을 뜻한다.
나는 얼굴 인식 기능만 사용할 거라 위 설정이 굳이 필요하지는 않았지만 한번 넣어봤다. 설정에 관한 자세한 정보는 공식 문서에서 확인해보자.
이제 ImageProcessor
로 이미지를 분석할 수 있다. 나머지는 갤러리에서 불러온 이미지를 ImageProcessor
에 넣고 결과값으로 나타난 얼굴 영역을 이미지 위에 그리는 것 뿐이다.
...
val faces = remember { mutableStateListOf<Face>() }
LaunchedEffect(imageBitmap) {
faces.clear()
val image = InputImage.fromBitmap(imageBitmap.asAndroidBitmap(), 0)
faces.addAll(ImageProcessor.processInputImage(image))
}
...
Canvas(
modifier = Modifier.fillMaxSize()
) {
drawImage(
image = imageBitmap
)
faces.forEach { face ->
val rect = face.boundingBox
drawRect(
color = Color.Green,
style = Stroke(
width = 2.dp.toPx()
),
topLeft = Offset(rect.left.toFloat(), rect.top.toFloat()),
size = Size(rect.width().toFloat(), rect.height().toFloat()),
)
}
}
이미지를 불러와서 imageBitmap
값이 변하면 LaunchedEffect
블럭 내 코드가 동작한다.
ImageProcessor.processInputImage()
로 불러온 이미지를 분석하여 나온 얼굴값 List<Face>
를 faces
에 모두 넣어준다.
그리고 Canvas
에서 각 얼굴마다 boundingBox
객체를 가져와 사각형으로 해당 얼굴 위치를 그려준다.
얼굴 인식이 정상적으로 작동되는 것을 확인할 수 있다. 실제로 사용해보면 꽤 속도도 빠르고 정확도도 높아서 배포된 공식 라이브러리만으로도 괜찮은 앱을 만들 수 있을 거 같다는 생각이 들었다.
Github 링크
참고문서: 안드로이드 공식 문서