https://github.com/kimmandoo/android-drill/tree/main/FaceMesh
안드로이드 온디바이스 AI API로 제공되는 라이브러리 중 MLKit이 있다. 그 중 얼굴 Mesh 감지를 이용한 졸음감지를 해보려고 단위 기능 개발을 진행했다.
https://developers.google.com/ml-kit/vision/face-mesh-detection?hl=ko
굉장히 API 가 잘돼있다. 따로 asset 추가할 필요없이 gradle 의존성을 추가하면 번들링될 때 포함되어 빌드된다.
Mesh를 이용하는 이유는 눈 주변의 point들을 감지해서 눈이 감겼는지, 떠있는 지 확인할 수 있는 가장 간편한 방법이라고 생각했기 때문이다.
이렇게 생긴 그물망이 얼굴이 감지되면 부착되고, 각 파트별로 point가 지정되어있고, contour로 유형이 분류 되어있다.
3점을 조합해서 구역을 감지하는 것도 된다. 그래서 예시로 나온 문장을 보면 아래와 같다.
삼각형 정보: 감지된 얼굴의 논리적 삼각형 표면을 나타내는 데 사용됩니다. 각 삼각형에는 3D 포인트 3개가 포함되어 있습니다. 예를 들어 지점 #0, #37, #164는 코와 입술 사이에 작은 삼각형 영역을 구성합니다.
구현을 위해 필요한 라이브러리로는 아래와 같다.
[versions]
faceMeshDetection = "16.0.0-beta3"
cameraCore = "1.3.4"
[libraries]
androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraCore" }
androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraCore" }
androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "cameraCore" }
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraCore" }
face-mesh-detection = { module = "com.google.mlkit:face-mesh-detection", version.ref = "faceMeshDetection" }
사진을 찍는 게 아니라, 카메라를 띄워서 스트리밍같이 사용할 것이다. camera-core가 핵심이다.
카메라를 쓰려면 우선 Manifest를 지정해두고, 권한 받는 과정이 필요하다.
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-permission android:name="android.permission.CAMERA" />
private fun checkPermission() = REQUIRED_PERMISSIONS.all {
ContextCompat.checkSelfPermission(baseContext, it) == PackageManager.PERMISSION_GRANTED
}
private fun requestPermissions() {
ActivityCompat.requestPermissions(
this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS
)
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_CODE_PERMISSIONS) {
if (checkPermission()) {
openCamera()
} else {
Toast.makeText(
this,
"카메라 권한 필요",
Toast.LENGTH_SHORT
).show()
finish()
}
}
}
companion object {
private const val REQUEST_CODE_PERMISSIONS = 10
private val REQUIRED_PERMISSIONS = arrayOf(Manifest.permission.CAMERA)
}
`REQUEST_CODE_PERMISSIONS`는 알아서 넣으면 된다.
`onRequestPermissionsResult`는 권한 요청에 대한 REQUEST_CODE를 검증하기 위한 함수인데 ActivityResult로 만들어서 launcher로 받아도 무난하다. 지금은 액티비티로 구현해서 이렇고 fragment로가면 지금이랑 다른 형태가 된다.
# FaceMeshDectector 사용하기
meshDetector = FaceMeshDetection.getClient(
FaceMeshDetectorOptions.Builder()
.setUseCase(FaceMeshDetectorOptions.FACE_MESH)
.build()
)
먼저 client를 만들어둔다. 이 안에 이미지를 넣어서 mesh를 씌우는 것이다. MLKit 공식문서에 있는 ImageAnalyzer를 사용했다.
그 전에 xml상에서 camera-view의 PreviewView를 사용하고 있기 때문에 카메라를 띄울 화면을 지정해준다. 카메라를 PreviewView에 묶어둔다고 생각하면 된다.
val preview = Preview.Builder()
.build()
.also {
it.setSurfaceProvider(previewView.surfaceProvider)
}
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
this,
CameraSelector.DEFAULT_FRONT_CAMERA,
preview,
imageAnalysis
)
} catch (e: Exception) {
Log.d(TAG, "error: $e")
}
카메라도 ProcessCameraPrivider로 열기때문에 여기서 연 cameraProvider를 surface로 감싸서 PreviewView에 넣어주는 코드라고 보면된다.
이제 Mesh 탐색 코드를 봐야된다. 카메라에서 가져온 Input을 사용하는데, 프레임 별로 반응하다보니 너무 빨라서 일부러 delay를 0.2s로 걸어놨다.
imageAnalysis = ImageAnalysis.Builder()
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
ImageAnalysis 객체를 만들어서 분석 조건을 지정한다. 가장 최근 프레임만 유지하고 나머지는 버리는 전략을 채택했다. 코루틴 Flow의 collectLatest와 동일한 개념이라 보면된다.
imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(this)) { imageProxy ->
val mediaImage = imageProxy.image
if (mediaImage != null) {
val image =
InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees)
meshDetector.process(image)
.addOnSuccessListener { faceMeshes ->
lifecycleScope.launch {
delay(200)
for (faceMesh in faceMeshes) {
val leftEye = faceMesh.getPoints(FaceMesh.LEFT_EYE)
val rightEye = faceMesh.getPoints(FaceMesh.RIGHT_EYE)
Log.d(TAG, "face: $leftEye || $rightEye")
val leftEyeOpen = isEyeOpen(leftEye)
val rightEyeOpen = isEyeOpen(rightEye)
Log.d(TAG, "Left eye open: $leftEyeOpen, Right eye open: $rightEyeOpen")
// 양쪽 눈의 상태에 따라 전체적인 눈 감김 여부 판단
val bothEyesOpen = leftEyeOpen && rightEyeOpen
Log.d(TAG, "Both eyes open: $bothEyesOpen")
}
}
}
.addOnFailureListener { e ->
Log.d(TAG, "error: $e")
}
.addOnCompleteListener {
imageProxy.close()
}
}
}
이제 눈의 가로, 세로 양끝 점을 기준으로 얼마나 감겼는지 판단하는 함수만 작성하면 끝난다.
private fun isEyeOpen(eyePoints: List<FaceMeshPoint>): Boolean {
// 눈의 세로 양 끝
val topPoint = eyePoints.minByOrNull { it.position.y }
val bottomPoint = eyePoints.maxByOrNull { it.position.y }
// 눈의 가로 양 끝
val leftPoint = eyePoints.minByOrNull { it.position.x }
val rightPoint = eyePoints.maxByOrNull { it.position.x }
if (topPoint != null && bottomPoint != null && leftPoint != null && rightPoint != null) {
val eyeHeight = bottomPoint.position.y - topPoint.position.y
val eyeWidth = rightPoint.position.x - leftPoint.position.x
val aspectRatio = eyeHeight / eyeWidth
// 종횡비가 임계값보다 크면 눈이 떠져있음.
val isOpen = aspectRatio > EYE_ASPECT_RATIO_THRESHOLD
return isOpen
}
return false
}
EYE_ASPECT_RATIO_THRESHOLD는 필요한 경우 바꿔서 사용하면 된다. 나는 0.2f로 했다.
도움이 됐다면 댓글이나 공감 버튼 한 번씩 누르고 가주세요!
'Android 🖥️ > 삽질⛏️' 카테고리의 다른 글
Streaming과 LoggingInterceptor 상관관계 (1) | 2024.11.25 |
---|---|
ROS - Android 통신 하기(feat. WebSocket, Okhttp3) (0) | 2024.08.27 |
Paging3 라이브러리 사용하기 (0) | 2024.07.30 |
lifecycleScope와 viewLifecycleOwner.lifecycleScope (0) | 2024.07.27 |
RecyclerView에서 당기는 행동 처리 - edgeEffectFactory (0) | 2024.07.24 |