08
26

https://github.com/kimmandoo/android-drill/tree/main/FaceMesh

 

android-drill/FaceMesh at main · kimmandoo/android-drill

기술 연습용 repo. Contribute to kimmandoo/android-drill development by creating an account on GitHub.

github.com

 

안드로이드 온디바이스 AI API로 제공되는 라이브러리 중 MLKit이 있다. 그 중 얼굴 Mesh 감지를 이용한 졸음감지를 해보려고 단위 기능 개발을 진행했다.

https://developers.google.com/ml-kit/vision/face-mesh-detection?hl=ko

 

얼굴 메시 감지  |  ML Kit  |  Google for Developers

이 페이지는 Cloud Translation API를 통해 번역되었습니다. 얼굴 메시 감지 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. 달리 명시되지 않는 한 이 페이지의 콘

developers.google.com

굉장히 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로 했다.

 

도움이 됐다면 댓글이나 공감 버튼 한 번씩 누르고 가주세요!

 

반응형
COMMENT