OpenCV LBP Cascade Classifier를 이용한 얼굴 인식 안드로이드 앱 만들기

Kim JuYoung·2023년 10월 24일
0

OpenCV Android Kotlin

목록 보기
2/3
post-thumbnail

💡 공부하면서 적어가는 과정에서 잘못된 정보가 표기될 수 있습니다. 해당 부분은 댓글을 통해 지적해주시면 감사하겠습니다.

카메라로 촬영되는 이미지에서 특정 객체와의 거리를 계산하기 위해 먼저 객체를 감지하는 작업을 해야한다.


그러나 그러한 작업을 한 번도 해본적이 없어서 우선 안드로이드에서 OpneCV를 이용한 객체 인식이 가능한지 아닌지 실현가능성을 확인하기 위해 우선적으로 사람 얼굴을 인식하는 앱을 만들어 보려고 한다.


OpenCV는 미리 학습된 분류기를 제공하는데 이번에는 이러한 분류기를 이용하여 사람의 얼굴을 감지하고 화면에 표시하는 애플리케이션을 만들면서 공부해보려고 한다.



객체 감지를 위한 분류기

OpenCV는 객체 감지를 위해 미리 학습된 lbpcascadeshaarcascades를 제공하고 있다.

이러한 것들을 분류기 (Classifier) 라고 하는데 객체 검출을 위한 검출기(감지기)의 일종이다.


Haar classifer

하르 분류기는 영상에서 특정 사각형 영역을 더하거나 빼는 과정을 통해 구해지는 유사-하르(Haar-like)을 사용한다. (중략) 하르 분류기는 처음부터 얼굴 인식을 위해 개발된 분류기인데, 일반적으로 히스토그램 균등화 및 크기 정규화된 영상 패치를 분류기에 입력하고, 영상 패치에 관심 객체가 포함되어 있는지 알려준다. (OpenCV 제대로 배우기)

대부분의 경우 하르 분류기는 지역 이진 패턴 분류기 보다 상대적으로 정확도가 높다고 한다. 그러나 학습 시간이 상대적으로 많이 드는 단점이 있다.


LBP Classifier

LBPLocal binary pattern의 약자인데, 밑에서 얼굴 인식 애플리케이션을 구현할 때 사용할 분류기가 바로 LBP Cascade Classifier이다.


지역 이진 패턴 분류기라고 하는 이 분류기는 각 픽셀(화소)의 인접한 화소의 명도를 비교하여 비트값을 반환하고 특정 순서대로 읽어들이는 방식으로 동작하는데,


이러한 동작 방식은 다른 픽셀과의 상대적인 명암의 크기를 비교하기 때문에 조명 변화에 덜 민감하며, 단순하지만 빠르다는 장점으로 높게 평가받고 있다고 한다.

Haar 분류기는 처음부터 얼굴 인식을 위해 개발되었지만 최초의 LBP는 텍스트 인식을 위해 개발 되었다고 한다. 그러나 LBP의 장점 및 특성으로 객체 인식 및 얼굴 인식 분야에서도 사용된다고 한다.

OpenCV에서는 이러한 방식으로 개발된 분류기를 미리 학습해서 제공하고 있다.


추가적으로 학습을 하는 방법은 나중에 작성하고 지금은 OpenCV에서 제공하는 lbpcascade_frontalface.xml를 이용하여 얼굴 인식 애플케이션을 구현해보고자 한다.



얼굴 인식 앱 구현

안드로이드 애플리케이션에서 얼굴 인식을 구현하는 과정은 비교적 간단했다.


OpenCV에서 미리 제공하는 분류기를 이용하면 간단한 얼굴 인식 앱을 만들 수 있는데, 사진 촬영 과정이 필요하므로 Jetpack 라이브러리의 CameraX API를 이용하여 만드는 과정이 포함되어 있다.


해당 라이브러리의 설명 부분은 이 글을 참고하면 도움이 된다.

1. 프로젝트 생성

먼저 프로젝트를 생성하고 OpenCV 라이브러리를 연결해야한다. 해당 부분은 이 글에 자세히 적어놓았다. 그리고 액티비티 클래스 코드와 레이아웃 XML 코드 그리고 ImageAnalysis 코드 이렇게 총 3개의 코드로 기반 코드를 작성한다.

ObjectDetectionActivity.kt

package com.example.myapplication

import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.ImageCapture
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.core.CameraSelector
import android.util.Log
import androidx.camera.view.LifecycleCameraController
import androidx.camera.view.PreviewView
import com.example.myapplication.analyzer.FaceDetectAnalyzer
import com.example.myapplication.databinding.ActivityObjectDetectionBinding

typealias FaceListener = (luma: Double) -> Unit

class ObjectDetectionActivity : AppCompatActivity() {
    private lateinit var viewBinding: ActivityObjectDetectionBinding
    private lateinit var cameraExecutor: ExecutorService

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewBinding = ActivityObjectDetectionBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)

        // Request camera permissions
        if (allPermissionsGranted()) {
            cameraExecutor = Executors.newSingleThreadExecutor()
            startCamera()
        } else {
            ActivityCompat.requestPermissions(
                this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
        }
    }

    private fun startCamera() {
        val previewView: PreviewView = viewBinding.viewFinder
        val cameraController = LifecycleCameraController(baseContext)
        cameraController.bindToLifecycle(this)
        cameraController.cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA
        cameraController.setImageAnalysisAnalyzer(cameraExecutor, FaceDetectAnalyzer { luma ->
            Log.d(TAG, "Average luminosity: $luma")
        })
        previewView.controller = cameraController
    }


    private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all {
        ContextCompat.checkSelfPermission(
            baseContext, it) == PackageManager.PERMISSION_GRANTED
    }

    override fun onDestroy() {
        super.onDestroy()
        cameraExecutor.shutdown()
    }

    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()
            }
        }
    }

    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()
    }
}

activity_object_detection.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" />

</androidx.constraintlayout.widget.ConstraintLayout>

FaceDetectAnalyzer

package com.example.myapplication.analyzer

import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import com.example.myapplication.FaceListener
import java.nio.ByteBuffer

class FaceDetectAnalyzer(private val listener: FaceListener) : 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()
    }
}

현재까지 진행 상황에서 앱을 실행하면 CameraXPreview를 통해 화면에 미리보기를 구성하고 Analysis를 이용하여 구현된 FaceDetectAnalyzer에서 현재 촬영되는 이미지의 평균 광도를 Logcat에 반환한다.


2. LBP Cascade 분류기 추가

OpenCV 에서는 미리 학습된 분류기를 제공하고 있다. 분류기는 OpenCV Github 사이트에서 다운로드를 해도 되고 OpenCV 라이브러리 폴더 내부에 있는 분류기를 사용해도 좋다.


폴더 내부에 있는 분류기를 사용할 경우 \opencv\etc\lbpcascades\lbpcascade_frontalface.xml파일을 OpenCV 패키지의 res -> raw 폴더에 넣어주면 된다.

만약 raw 폴더가 없다면 새로 만들면 된다.

분류기 xml 파일을 raw 폴더에 넣게 되면 다음과 같은 결과를 볼 수 있다.

FaceDetectAnalyzer 구현하기

프로젝트를 생성할 때 추가했던 FaceDetectAnalyzer를 마저 구현해보자.

package com.example.face_detect_analyze_android

import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.ImageFormat
import android.graphics.Rect
import android.graphics.YuvImage
import android.media.Image
import android.util.Log
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import org.opencv.android.OpenCVLoader
import org.opencv.android.Utils
import org.opencv.core.Core
import org.opencv.core.CvType
import org.opencv.core.Mat
import org.opencv.core.MatOfRect
import org.opencv.core.Scalar
import org.opencv.imgproc.Imgproc
import org.opencv.objdetect.CascadeClassifier
import java.io.ByteArrayOutputStream
import java.io.File
import java.io.FileOutputStream
import java.nio.ByteBuffer

class FaceDetectAnalyzer(
    context: Context,
    private val listener: FaceListener,
) : ImageAnalysis.Analyzer {
    private var lbpCascadeClassifier: CascadeClassifier? = null

    init {
        if (OpenCVLoader.initDebug()) {

            val inputStream =  context.resources.openRawResource(org.opencv.R.raw.lbpcascade_frontalface)
            val file = File(context.getDir(
                "cascade", Context.MODE_PRIVATE),
                "lbpcascade_frontalface.xml")
            val fileOutputStream = FileOutputStream(file)
            // asd
            val data = ByteArray(4096)
            var readBytes: Int

            while (inputStream.read(data).also { readBytes = it } != -1) {
                fileOutputStream.write(data, 0, readBytes)
            }

            lbpCascadeClassifier = CascadeClassifier(file.absolutePath)

            inputStream.close()
            fileOutputStream.close()
            file.delete()
        }
    }

    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
    }

    @SuppressLint("UnsafeOptInUsageError")
    override fun analyze(imageProxy: ImageProxy) {
        val buffer = imageProxy.planes[0].buffer
        val yData = buffer.toByteArray()
        
        val yMat = Mat(imageProxy.height, imageProxy.width, CvType.CV_8UC1)
        yMat.put(0, 0, yData)

        val tyMat = yMat.t()
        yMat.release()

        val facesRects = MatOfRect()
        lbpCascadeClassifier?.detectMultiScale(tyMat, facesRects, 1.1, 3)

        listener(facesRects.toArray(), imageProxy.height.toFloat(), imageProxy.width.toFloat())

        tyMat.release()
        facesRects.release()
        imageProxy.close()
    }

}

변경점은 analyze 메서드를 수정했는데 imageProxy에서 반환되는 채널은 yuv이다.

lbp는 흑백으로 만들어야하는데 yuv에서 흑백 채널만 받으면 따로 변환과정이 필요 없기 때문에 y 채널만 바이트배열로 변환하였다.


그리고 OpneCV에서 사용하기 위해 Mat 객체를 생성하고 바이트배열을 Mat에 넣는다. 그런 다음 애플리케이션을 세로로 보고 동작하기 위해 전치행렬계산을 해주고 새로운 Mat 객체에 넣는다.

그런 다음 분류기를 이용해서 얼굴이 인식된 관심 구역을 리스트에 넣고 리스너를 동작시킨다.


ObjectDetectionActivity.kt 수정하기

package com.example.face_detect_analyze_android

import android.Manifest
import android.annotation.SuppressLint
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Rect
import android.os.Build
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.ImageCapture
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.core.CameraSelector
import android.util.Log
import android.util.Size
import androidx.camera.core.AspectRatio
import androidx.camera.core.ImageAnalysis
import androidx.camera.lifecycle.ProcessCameraProvider
import org.opencv.core.Mat
import org.opencv.core.MatOfRect
import org.opencv.core.Scalar
import org.opencv.imgproc.Imgproc
import androidx.camera.core.Preview
import androidx.camera.core.ResolutionSelector
import androidx.core.view.drawToBitmap
import com.example.face_detect_analyze_android.databinding.ActivityObjectDetectionBinding
import org.opencv.android.Utils


typealias FaceListener = (rects: Array<org.opencv.core.Rect>, cWidth: Float, cHeight: Float) -> Unit

class ObjectDetectionActivity : AppCompatActivity() {
    private lateinit var viewBinding: ActivityObjectDetectionBinding
    private lateinit var cameraExecutor: ExecutorService
    private var imageCapture: ImageCapture? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewBinding = ActivityObjectDetectionBinding.inflate(layoutInflater)
        setContentView(viewBinding.root)

        if (allPermissionsGranted()) {
            cameraExecutor = Executors.newSingleThreadExecutor()
            startCamera()
        } else {
            ActivityCompat.requestPermissions(
                this, REQUIRED_PERMISSIONS, REQUEST_CODE_PERMISSIONS)
        }
    }

    @SuppressLint("RestrictedApi")
    private fun startCamera() {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(this)

        cameraProviderFuture.addListener({
            val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get()

            val preview = Preview.Builder()
                .build()
                .also {
                    it.setSurfaceProvider(viewBinding.viewFinder.surfaceProvider)
                }

            imageCapture = ImageCapture.Builder().build()


            val imageAnalyzer = ImageAnalysis.Builder()
                .setTargetAspectRatio(AspectRatio.RATIO_16_9)
                .build()
                .also {
                    it.setAnalyzer(cameraExecutor, FaceDetectAnalyzer(this@ObjectDetectionActivity){ rects, cWidth, cHeight ->
                        runOnUiThread {
                            viewBinding.overlayView.updateFaces(rects, cWidth, cHeight)
                        }
                    })}


            val cameraSelector = CameraSelector.DEFAULT_BACK_CAMERA

            try {
                cameraProvider.unbindAll()
                cameraProvider.bindToLifecycle(
                    this, cameraSelector, preview, imageCapture, imageAnalyzer)

            } 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()
    }

    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()
            }
        }
    }

    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()
    }
}

우선 해당 액티비티의 리스너를 수정하였다. 관심구역 리스트와 그릴 때 보정하기 위한 가로 및 세로를 추가하면 된다.


그리고 imageAnalyzer 생성 부분에
카메라 비율과 리스너를 수정해준다.

여기 카메라 비율을 수정해주지 않으면 해상도가 낮아져서 제대로 인식을 하지 못 한다.


FaceOverlayView.kt 추가

package com.example.face_detect_analyze_android

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.View
import org.opencv.core.Rect

class FaceOverlayView(context: Context, attrs: AttributeSet? = null) : View(context, attrs) {
    private var detectedFaces: Array<Rect>? = null
    private var cWidth: Float = 0f
    private var cHeight: Float = 0f

    private val paint: Paint = Paint().apply {
        color = Color.GREEN
        style = Paint.Style.STROKE
        strokeWidth = 10f
    }

    fun updateFaces(detectedFaces: Array<Rect>, cWidth: Float, cHeight: Float) {
        this.detectedFaces = detectedFaces
        this.cWidth = cWidth
        this.cHeight = cHeight
        invalidate()
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        detectedFaces?.forEach { faceRect ->
            val scaleX: Float = width.toFloat() / cWidth
            val scaleY: Float = height.toFloat() / cHeight

            // 좌우 반전 적용
            val left = (width - (faceRect.x + faceRect.width) * scaleX)
            val right = (width - faceRect.x * scaleX)

            canvas.drawRect(left, faceRect.y * scaleY, right, (faceRect.y + faceRect.height) * scaleY, paint)
        }
    }

}

priview 위에 얼굴이 인식되었다고 표시할 상자를 그리기 위해 커스텀 뷰를 구현한다.

<?xml version="1.0" encoding="utf-8"?>
<FrameLayout
    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=".ObjectDetectionActivity">

    <androidx.camera.view.PreviewView
        android:id="@+id/viewFinder"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />

    <com.example.face_detect_analyze_android.FaceOverlayView
        android:id="@+id/overlayView"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />


</FrameLayout>

그리고 xml을 이렇게 변경해주면 된다.


실행 화면


전체 소스 코드

https://github.com/wndudwkd003/face-detect-analyzer-android


아쉬운점

OpenCV JAVA Camera를 사용하면 더 정확하게 인식이 되던데 여러 폰에서 실험을 해보니 프레임도 낮아지고 실제로 사용하기엔 무리가 있을정도여서 CameraX를 사용해서 구현을 해보았다.

근데 CameraX에는 직접 오버레이를 구현하는 기술이 없어서 추가로 박스를 그리는 오버레이뷰를 만들어야했고 그 때문인지 실제 얼굴에서 조금 빗겨나가는 모습이 많이 보인다.

이부분을 어떻게 해결하려고 이미지에 직접 추가하는 방식도 넣어보았는데 카메라 비율때문인지 이상하게 찍히는 현상이 생긴다.



참고

OpenCV 제대로 배우기
OpenCV를 활용한 컴퓨터 비전 프로그래밍

profile
안드로이드와 인공지능에서 살아남기

0개의 댓글

관련 채용 정보