반응형

 

OpenCV 배포시 포함되어 있는 얼굴 검출 C++코드를 NDK를 이용하여 Android에서 동작하도록 수정하였습니다.  

 

안드로이드 +  NDK  카메라 기본코드에 단순히 C++코드만 옮겨오면 되는 줄 알았는데 고려해야 하는게 생각보다 많군요.. 

 

사용한 C++ 코드는 다음 위치에서 가져왔습니다.

https://github.com/opencv/opencv/blob/master/samples/cpp/tutorial_code/objectDetection/objectDetection.cpp




다음 순서로 설명합니다. 

 


1. 프로그램 흐름 및 실행결과

2. 코드 수정 및 사용방법

3. 캡쳐 버튼 추가하기

4. 검출된 얼굴 갯수 반환받기




2016. 12. 09 최초작성

2019. 08. 15  OpenCV 4.1.1용 추가

2020. 06. 27 Android 10.0, OpenCV 4.3

2021. 05. 05 Andorid 11.0, OpenCV 4.5.1

2022. 06. 01 Android 12.0, OpenCV 4.5.5, 퍼미션 관련 코드 수정

                     ndk-build 방식에서 문제가 많이 발생하여 제외시켰습니다.

 

1. 프로그램 흐름 및 실행결과

1-1. Android 프로젝트의 assets 폴더에 넣었던 XML 파일(Harr cascade 트레이닝 데이터)는 프로젝트 deploy시 같이 폰에 올라가지만 읽어오기 위해서는 안드로이 폰의 내부 저장소(Internal Storage)로 옮기는 작업이 필요합니다. (JAVA read_cascade_file)

 

 

1-2. 내부 저장소로부터 XML 파일(Harr cascade 트레이닝 데이터)을 읽어와 CascadeClassifier 객체를 생성후 자바로 넘겨줍니다.(C++ loadCascade)




1-3. 카메라로부터 영상을 읽어올 때  전면 카메라의 경우 영상이 뒤집혀서 읽히기 때문에  180도 회전 시켜줘야 합니다.(JAVA onCameraFrame)

 

 

1-4. JAVA에서 영상이 들어오기 시작하면 CascadeClassifier 객체를 인자로 해서 호출되어 얼굴 검출 결과를 영상에 표시해줍니다. (C++ detect)




1-5. 최종 결과를 안드로이드폰의 화면에 보여지도록 결과 Mat 객체를 리턴합니다.(JAVA onCameraFrame)

 

 

실행결과입니다. 얼굴 위치와 눈 위치가 검출된 결과입니다.  

이상하게도 화면이 세로( Portrait ) 방향일 때에는 검출이 안되고  아래 화면처럼 가로(Landscape) 방향일 때에만 검출이 됩니다.

Portrait 방향일때 안되는 것은 OpenCV Camera API 문제인 듯 싶습니다. 

 

 

주의 할 점은 이미지 프로세싱 혹은 컴퓨터 비전 기술 특성상 장소에 따라 제대로 검출이 안되거나 오류가 있을 수 있습니다. 

가장 큰 원인은 장소마다 조명 상태가 다르기 때문입니다.  적절한 조명 상태에서 잘 검출이 됩니다.  






2. 코드 수정 및 사용방법

 

2-1.  다음 포스팅을 참고하여 OpenCV 지원하는 안드로이드 프로젝트를 생성합니다.

참고로 현재 Android Studio에서 공식 지원하는 방법은 CMake 사용하는 방법입니다. 

 


Android NDK + OpenCV 카메라 예제 및 프로젝트 생성방법(CMake 사용)
http://webnautes.tistory.com/1054




2-2. 프로젝트 패널을 Project 뷰로 변경한 후  app / src / main을 선택한 상태에서 마우스 우클릭하여 보이는 메뉴에서 New > Directory를 선택합니다. 

 



assets라고 입력하고 엔터를 누르면  assets 이름의 디렉토리가 생성됩니다. 

 

 



2-3. 아래 링크를 각각 클릭하여 해당 페이지로 이동하면, Ctrl + S를 눌러서 파일을 저장합니다.

Twibap 님이 알려주신 방법입니다. (크롬 웹브라우저에서만 가능한 방법입니다. )

 

https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_eye_tree_eyeglasses.xml

 

https://raw.githubusercontent.com/opencv/opencv/master/data/haarcascades/haarcascade_frontalface_alt.xml



Ctrl키를 누른채 파일을 드래그하여 프로젝트 패널의 assets 디렉토리 위에서 왼쪽 마우스 버튼을 뗀 후, Refactor 버튼을 클릭하면 복사가 됩니다.

 



2-4. AndroidManifest.xml 파일에 외부 저장소 접근 권한을 추가해줍니다.

 

 

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />

 




API 29이상에서 필요한 부분입니다.

 

   android:requestLegacyExternalStorage="true"

 





2-5.  android 라이브러리를 사용하도록  CMakeLists.txt 파일을 수정합니다.  수정후 Sync Now를 클릭해줘야 합니다. 

 

 

find_library( android-lib android)

# Specifies libraries CMake should link to your target library. You
# can link multiple libraries, such as libraries you define in this
# build script, prebuilt third-party libraries, or system libraries.

target_link_libraries( # Specifies the target library.
        native-lib

       ${android-lib}

        lib_opencv

        # Links the target library to the log library
        # included in the NDK.
        ${log-lib} )




오른쪽 상단에 보이는 Sync Now를 클릭합니다. 

또는 메뉴에서 File > Sync Project with Gradle Files를 선택합니다.





2-6.  자바 코드 수정합니다.

 




수정 1.  다음 파란색 코드들을 추가 또는 수정합니다.

추가한 코드에서 빨간색이 보이는 부분에서 Alt + Enter를 눌러서 필요한 패키지를 임포트 하도록합니다



    protected void onCameraPermissionGranted() {
        List<? extends CameraBridgeViewBase> cameraViews = getCameraViewList();
        if (cameraViews == null) {
            return;
        }
        for (CameraBridgeViewBase cameraBridgeViewBase: cameraViews) {
            if (cameraBridgeViewBase != null) {
                cameraBridgeViewBase.setCameraPermissionGranted();

                read_cascade_file();
            }
        }
    }




    @Override
    protected void onStart() {
        super.onStart();
        boolean havePermission = true;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            if (checkSelfPermission(CAMERA) != PackageManager.PERMISSION_GRANTED
                    || checkSelfPermission(WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED
            ) {
                requestPermissions(new String[]{CAMERA, WRITE_EXTERNAL_STORAGE}, CAMERA_PERMISSION_REQUEST_CODE);
                havePermission = false;
            }
        }
        if (havePermission) {
            onCameraPermissionGranted();
        }
    }

 

  @Override
    @TargetApi(Build.VERSION_CODES.M)
    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        if (requestCode == CAMERA_PERMISSION_REQUEST_CODE && grantResults.length > 0
                && grantResults[0] == PackageManager.PERMISSION_GRANTED
                && grantResults[1] == PackageManager.PERMISSION_GRANTED) {
            onCameraPermissionGranted();
        }else{
            showDialogForPermission("앱을 실행하려면 퍼미션을 허가하셔야합니다.");
        }
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    }


    @TargetApi(Build.VERSION_CODES.M)
    private void showDialogForPermission(String msg) {

        AlertDialog.Builder builder = new AlertDialog.Builder( MainActivity.this);
        builder.setTitle("알림");
        builder.setMessage(msg);
        builder.setCancelable(false);
        builder.setPositiveButton("예", new DialogInterface.OnClickListener() {
            public void onClick(DialogInterface dialog, int id){
                requestPermissions(new String[]{CAMERA, WRITE_EXTERNAL_STORAGE}, CAMERA_PERMISSION_REQUEST_CODE);
            }
        });
        builder.setNegativeButton("아니오", new DialogInterface.OnClickListener() {
            public void onClick(DialogInterface arg0, int arg1) {
                finish();
            }
        });
        builder.create().show();
    }




수정 2.  사용하는 카메라를 변경합니다. 0을 1로 변경합니다. 에뮬레이터로 하는 경우에는 웹캠을 에뮬레이터에 연결한 후,  0으로 두어야 합니다. 

 

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN,
                WindowManager.LayoutParams.FLAG_FULLSCREEN);
        getWindow().setFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON,
                WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);

        setContentView(R.layout.activity_main);

        mOpenCvCameraView = (CameraBridgeViewBase)findViewById(R.id.activity_surface_view);
        mOpenCvCameraView.setVisibility(SurfaceView.VISIBLE);
        mOpenCvCameraView.setCvCameraViewListener(this);
        mOpenCvCameraView.setCameraIndex(1); // front-camera(1),  back-camera(0)
    }



수정 3.  기존 코드를 주석처리하고 cpp에 추가할 jni함수를 위한 네이티브 메소드 선언을 추가합니다.



    //public native void ConvertRGBtoGray(long matAddrInput, long matAddrResult);
    public native long loadCascade(String cascadeFileName );
    public native void detect(long cascadeClassifier_face,
                              long cascadeClassifier_eye, long matAddrInput, long matAddrResult);
    public long cascadeClassifier_face = 0;
    public long cascadeClassifier_eye = 0;




수정 4.  xml 파일을 가져오기 위한 메소드를 추가합니다. 

 

  • cpp 파일의 loadCascade 함수를 호출하도록 구현되어있는데 자바 함수를 사용하도록 변경해도 됩니다.
  • 현재 이미 파일을 copyFile 메소드를 이용해서 가져온 경우에 대한 처리가 빠져있습니다.  

 

 

private void copyFile(String filename) {

        AssetManager assetManager = this.getAssets();
        File outputFile = new File( getFilesDir() + "/" + filename );

        InputStream inputStream = null;
        OutputStream outputStream = null;

        try {
            Log.d( TAG, "copyFile :: 다음 경로로 파일복사 "+ outputFile.toString());
            inputStream = assetManager.open(filename);
            outputStream = new FileOutputStream(outputFile);

            byte[] buffer = new byte[1024];
            int read;
            while ((read = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, read);
            }
            inputStream.close();
            inputStream = null;
            outputStream.flush();
            outputStream.close();
            outputStream = null;
        } catch (Exception e) {
            Log.d(TAG, "copyFile :: 파일 복사 중 예외 발생 "+e.toString() );
        }

    }

    private void read_cascade_file(){
        copyFile("haarcascade_frontalface_alt.xml");
        copyFile("haarcascade_eye_tree_eyeglasses.xml");

        Log.d(TAG, "read_cascade_file:");

        cascadeClassifier_face = loadCascade( getFilesDir().getAbsolutePath() + "/haarcascade_frontalface_alt.xml");
        Log.d(TAG, "read_cascade_file:");

        cascadeClassifier_eye = loadCascade(  getFilesDir().getAbsolutePath() +"/haarcascade_eye_tree_eyeglasses.xml");
    }



추가한 코드에서 빨간색이 보이는 부분에서 Alt + Enter를 눌러서 필요한 패키지를 임포트 하도록합니다. 

 




수정 5. 기존 코드를 주석처리하고 얼굴 검출하는 cpp 코드를 호출하는 코드를 추가합니다. 

추가한 코드에서 빨간선이 보이는 부분에서 Alt + Enter를 눌러서 필요한 패키지를 임포트 하도록합니다. 

 

카메라로부터 영상을 가져올 때마다 호출되는 onCameraFrame 메소드에서  jni 함수 detect를 호출하도록 합니다. 



    @Override
    public Mat onCameraFrame(CameraBridgeViewBase.CvCameraViewFrame inputFrame) {

        matInput = inputFrame.rgba();

        if ( matResult == null )

            matResult = new Mat(matInput.rows(), matInput.cols(), matInput.type());

        //ConvertRGBtoGray(matInput.getNativeObjAddr(), matResult.getNativeObjAddr());
        Core.flip(matInput, matInput, 1);

        detect(cascadeClassifier_face,cascadeClassifier_eye, matInput.getNativeObjAddr(),
                matResult.getNativeObjAddr());


        return matResult;
    }





2-7.  JNI 함수를 위한 처리입니다.  

 



자바 코드에서 loadCascade 메소드에 각각  마우스 커서를 가져가면 보이는 빨간 전구를 클릭한 후, 

 

 

보이는 메뉴에서  Create JNI function을 클릭합니다.

 

 

native-lib.cpp를 선택합니다. 

 

 

자바 코드의 loadCascade 메소드에 대응되는 cpp 함수가 생성된 후, 자바 코드에서 loadCascade 함수는 검은색으로 변합니다.



detect 메소드도 같은 방식으로 진행합니다.

 



두 메소드 이름이 모두 검은색으로 보여야합니다.

 



2-8. native-lib.cpp 파일을 다음처럼 수정합니다. 





 

#include <jni.h>
//#include <string>
#include <opencv2/opencv.hpp>

#include <android/log.h>

using namespace std;
using namespace cv;

float resize(Mat img_src, Mat &img_resize, int resize_width){

    float scale = resize_width / (float)img_src.cols ;
    if (img_src.cols > resize_width) {
        int new_height = cvRound(img_src.rows * scale);
        resize(img_src, img_resize, Size(resize_width, new_height));
    }
    else {
        img_resize = img_src;
    }
    return scale;
}



//extern "C"
//JNIEXPORT void JNICALL
//Java_com_tistory_webnautes_useopencvwithcmake_MainActivity_ConvertRGBtoGray(JNIEnv *env,
//                                                                            jobject thiz,
//                                                                            jlong mat_addr_input,
//                                                                            jlong mat_addr_result) {
//    // TODO: implement ConvertRGBtoGray()
//    Mat &matInput = *(Mat *)mat_addr_input;
//    Mat &matResult = *(Mat *)mat_addr_result;
//
//    cvtColor(matInput, matResult, COLOR_RGBA2GRAY);
//}


extern "C"
JNIEXPORT jlong JNICALL
Java_com_tistory_webnautes_useopencvwithcmake_MainActivity_loadCascade(JNIEnv *env, jobject thiz, jstring cascade_file_name) {



const char *nativeFileNameString = env->GetStringUTFChars(cascade_file_name, 0);

    string baseDir("");
    baseDir.append(nativeFileNameString);
    const char *pathDir = baseDir.c_str();

    jlong ret = 0;
    ret = (jlong) new CascadeClassifier(pathDir);
    if (((CascadeClassifier *) ret)->empty()) {
        __android_log_print(ANDROID_LOG_DEBUG, "native-lib :: ",
                            "CascadeClassifier로 로딩 실패  %s", nativeFileNameString);
    }
    else
        __android_log_print(ANDROID_LOG_DEBUG, "native-lib :: ",
                            "CascadeClassifier로 로딩 성공 %s", nativeFileNameString);


    env->ReleaseStringUTFChars(cascade_file_name, nativeFileNameString);

    return ret;


}


extern "C"
JNIEXPORT void JNICALL
Java_com_tistory_webnautes_useopencvwithcmake_MainActivity_detect(JNIEnv *env, jobject thiz,
                                                                  jlong cascade_classifier_face,
                                                                  jlong cascade_classifier_eye,
                                                                  jlong mat_addr_input,
                                                                  jlong mat_addr_result) {


   Mat &img_input = *(Mat *) mat_addr_input;
    Mat &img_result = *(Mat *) mat_addr_result;

    img_result = img_input.clone();

    std::vector<Rect> faces;
    Mat img_gray;

    cvtColor(img_input, img_gray, COLOR_BGR2GRAY);
    equalizeHist(img_gray, img_gray);

    Mat img_resize;
    float resizeRatio = resize(img_gray, img_resize, 640);

    //-- Detect faces
    ((CascadeClassifier *) cascade_classifier_face)->detectMultiScale( img_resize, faces, 1.1, 2, 0|CASCADE_SCALE_IMAGE, Size(30, 30) );


    __android_log_print(ANDROID_LOG_DEBUG, (char *) "native-lib :: ",
                        (char *) "face %d found ", faces.size());

    for (int i = 0; i < faces.size(); i++) {
        double real_facesize_x = faces[i].x / resizeRatio;
        double real_facesize_y = faces[i].y / resizeRatio;
        double real_facesize_width = faces[i].width / resizeRatio;
        double real_facesize_height = faces[i].height / resizeRatio;

        Point center( real_facesize_x + real_facesize_width / 2, real_facesize_y + real_facesize_height/2);
        ellipse(img_result, center, Size( real_facesize_width / 2, real_facesize_height / 2), 0, 0, 360,
                Scalar(255, 0, 255), 30, 8, 0);


        Rect face_area(real_facesize_x, real_facesize_y, real_facesize_width,real_facesize_height);
        Mat faceROI = img_gray( face_area );
        std::vector<Rect> eyes;

        //-- In each face, detect eyes
        ((CascadeClassifier *) cascade_classifier_eye)->detectMultiScale( faceROI, eyes, 1.1, 2, 0 |CASCADE_SCALE_IMAGE, Size(30, 30) );

        for ( size_t j = 0; j < eyes.size(); j++ )
        {
            Point eye_center( real_facesize_x + eyes[j].x + eyes[j].width/2, real_facesize_y + eyes[j].y + eyes[j].height/2 );
            int radius = cvRound( (eyes[j].width + eyes[j].height)*0.25 );
            circle( img_result, eye_center, radius, Scalar( 255, 0, 0 ), 30, 8, 0 );
        }
    }


}



이제 안드로이드 디바이스에 설치해보면 됩니다. 

얼굴이 인식되는 것을 볼 수 있습니다. 안드로이드폰을 가로로 돌려야 인식이됩니다.



3. 캡쳐 버튼 추가하기

영상에 검출된 얼굴 위치, 눈 위치를 그려넣는 코드와 최종 영상을 저장하는 코드간에 동기화를 맞추기 위해서 세마포어를 사용했습니다.

 

3-1. activity_main.xml 파일에 버튼을 추가합니다.

 

<?xml version="1.0" encoding="utf-8"?>

    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    tools:context=".MainActivity" >

    <Button
        android:id="@+id/button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center_horizontal"
        android:text="캡쳐" />
   
    <org.opencv.android.JavaCameraView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:id="@+id/activity_surface_view" />

</LinearLayout>



3-2. MainActivity.java 파일에 세마포어를 사용하기 위한 코드를 추가합니다. 

추가한 코드에서 빨간색이 보이는 부분에서 Alt + Enter를 눌러서 필요한 패키지를 임포트 하도록합니다. 

    private final Semaphore writeLock = new Semaphore(1);

    public void getWriteLock() throws InterruptedException {
        writeLock.acquire();
    }

    public void releaseWriteLock() {
        writeLock.release();
    }

 




3-3. 기존 코드에서 카메라 접근하는 부분을 세마포어로 둘러싸둡니다.

 

    @Override
    public Mat onCameraFrame(CameraBridgeViewBase.CvCameraViewFrame inputFrame) {

        try {
            getWriteLock();
            matInput = inputFrame.rgba();

            if ( matResult == null )

                matResult = new Mat(matInput.rows(), matInput.cols(), matInput.type());

            Core.flip(matInput, matInput, 1);

            detect(cascadeClassifier_face,cascadeClassifier_eye, matInput.getNativeObjAddr(),
                    matResult.getNativeObjAddr());

        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        releaseWriteLock();
        return matResult;
    }





3-4. 버튼 클릭시 사진을 저장하기 위한 코드를 onCreate 메소드에 추가합니다. 

추가한 코드에서 빨간색이 보이는 부분에서 Alt + Enter를 눌러서 필요한 패키지를 임포트 하도록합니다.

 

Button button = (Button)findViewById(R.id.button);
        button.setOnClickListener(new View.OnClickListener() {
            public void onClick(View v) {

                try {
                    getWriteLock();

                    File path = new File(String.valueOf(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)));

                    path.mkdirs();
                    File file = new File(path, "image.jpg");

                    String filename = file.toString();

                    Imgproc.cvtColor(matResult, matResult, Imgproc.COLOR_BGR2RGBA);
                    boolean ret  = Imgcodecs.imwrite( filename, matResult);
                    if ( ret ) Log.d(TAG, "SUCESS");
                    else Log.d(TAG, "FAIL");


                    Intent mediaScanIntent = new Intent( Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
                    mediaScanIntent.setData(Uri.fromFile(file));
                    sendBroadcast(mediaScanIntent);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }


                releaseWriteLock();

            }
        });




버튼을 클릭하여 저장한 영상입니다. 

 




참고

https://www.javacodegeeks.com/2012/10/locking-with-semaphore-example.html

 

반응형

진행해본 결과물을 기록 및 공유하는 공간입니다.
잘못된 부분이나 개선점을 알려주시면 반영하겠습니다.


소스코드 복사시 하단에 있는 앵커 광고의 왼쪽 위를 클릭하여 닫은 후 해야 합니다.


문제가 생기면 포스트와 바뀐 환경이 있나 먼저 확인해보세요.
질문을 남겨주면 가능한 빨리 답변드립니다.


제가 쓴 책도 한번 검토해보세요 ^^

  • 네이버 블러그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 카카오스토리 공유하기

댓글을 달아 주세요

TistoryWhaleSkin3.4">
  1. 이전 댓글 더보기