• 텐서플로우로 이미지인식 모바일앱 만들어보기 - 5-2 (간단히 앱만들어보기) [머신러닝/딥러닝]
  • 공돌이
    조회 수: 22384, 2017.06.05 12:24:40
  • 깃헙에 있는 공식예제 안드로이드앱은 훌륭하지만, 텐서플로우 인셉션엔진(이미지인식)이 안드로이드에서 어떤 식으로 구동되는가 하는 구조를 파악하기에는 좀 복잡한 감이 없지 않죠.

     

    그래서 이번시간에는 간단한, 느므느므 간단하게 이미지를 입력받아 인식하는 앱을 한번 직접 만들어 보겠습니다.

     

    - 그냥 정말 간단히 재빨리 만든 앱이니 소스구조나 UI가 허접하다고 욕하진 말아주세염...

    - 쉽게하려고, 카메라 기능이나 이미지피커기능 등은 깃헙에 올라와 있는 오픈소스라이브러리를 이용했습니다. 

    - 이 앱의 소스는 https://github.com/MindorksOpenSource/AndroidTensorFlowMachineLearningExample 를 참고해서, 제 맘대로 이것저것 추가해서 만들었습니다.

     

     

    I. 앱의 기능

        - 최신의 텐서플로우 라이브러리(1.2-rc0) 사용

        - 이전 시간에 직접 학습시킨 그래프파일을 탑재 

        - 실행하면 소스를 선택:  내 사진함, URL직접입력, 카메라 중에서 선택

        - 사진함: 사진을 선택하면 해당사진을 분석해서 썸네일과 함께 결과값(추정치) 을 앱 상단에 표시

        - URL : 인터넷 상의 이미지 URL 을 직접입력하면 썸네일과 함께 결과값(추정치) 을 앱 상단에 표시 

        - 카메라: 원하는 곳에 카메라를 찍으면 (detect버튼) 썸네일과 함께 결과값(추정치) 을 앱 상단에 표시 

     

         * 사용한 jcenter 외부 라이브러리 (네...게을러서 쉽게 하려고 가져다 씁니다):

            - 갤러리 이미지피커(ioneday imageselector): https://github.com/ioneday/ImageSelector 

           - 카메라 (gogopop CameraKit) : https://github.com/gogopop/CameraKit-Android

           - permission (Karumi Dexter): https://github.com/Karumi/Dexter

           - tensorflow 1.2-rc0 (현재시점 최신)

     

    정도입니다. 뭔가 좀 부족하지만...이것저것 기능을 덧붙이려니 시간이 걸릴듯 해서 심플하게 이걸로 정리했습니다.

     

     

    아래 스크린샷은, 첫번째가 실행했을때, 두번째는 카메라를 이용해서 이미지인식할때입니다. (에뮬레이터상이라 카메라뷰가 누워있는건 이해를...)

    스마트폰으로 dandelion 띄워서 인식시켜봤어요... 인식률이 높지 않은데, 이후 튜토리얼에서 다양한 방법을 통해 인식률을 높이는 방법도 소개하겠습니다.

     

     

     

    II. 앱 코드 작성

    안드로이드스튜디오를 실행하고, Empty Activity 를 선택해서 프로젝트를 만듭니다.

     

    1) build.gradle

    우선, app 의 build.gradle 을 열어서 dependencies 부분에 아래와 같은 필요한 외부 라이브러리들을 추가해줍니다.

     

    compile 'com.android.support:recyclerview-v7:25.2.0'
    compile 'com.github.bumptech.glide:glide:3.7.0'
    compile 'com.commit451:PhotoView:1.2.4'
    compile 'com.isseiaoki:simplecropview:1.0.13'
    compile 'com.yongchun:com.yongchun.imageselector:1.1.0'
    compile 'org.tensorflow:tensorflow-android:1.2.0-rc0'
    compile 'com.karumi:dexter:4.1.0'

     

    그리고 이게 외부 라이브러리를 쓰다 보니 기존꺼랑 버전이 안맞는 경우가 생깁니다. 라이브러리를 로컬로 받아서 수정 후 재빌드 하는 방법도 있으나 귀찮으니 기존 라이브러리버전을 내용에 따라 수정합니다...^^;;

     

     

    2) activity_main.xml 레이아웃

     

    저는 디폴트값인 Constraint Layout을 사용했습니다만 편하신 레이아웃으로 아래와 비슷하게 만들어줍니다.

    (소스를 지정하는 버튼, 카메라뷰, 카메라 detect버튼, 결과를 표현할 이미지뷰와 텍스트뷰 등)

    리소스id는 : btnGallery (사진함에서 이미지가져올때)

                    btnUrl (URL값에서 이미지 가져올때)

                    btnCamera (카메라에서 읽어올때)

                    cameraView (카메라 프리뷰이미지 보여줄곳 - 외부라이브러리 추가하면 뷰 선택할때 cameraView를 찾으실 수 있습니다)

                    imgResult (분석이미지 썸네일표시용 이미지뷰)

                    txtResult (결과값 뿌려줄 텍스트뷰)

     

    3) style.xml

     외부라이브러리가 액션바 없는걸로 하다보니 충돌이 나는데, 물론 액티비티별로 지정해줘도 되지만 저는 게으르니까 전체 앱을 그냥 액션바없이 해주는걸로 변경함다.

    <resources>
    
        <!-- Base application theme. -->
        <style name="AppTheme" parent="Theme.AppCompat.Light.NoActionBar">
            <!-- Customize your theme here. -->
            <item name="colorPrimary">@color/colorPrimary</item>
            <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
            <item name="colorAccent">@color/colorAccent</item>
        </style>
    
    </resources>
    

     

     

    4) AndroidManifest.xml

    먼저 필요한 퍼미션을 추가해줌다. (WRITE EXTERNAL STORAGE는 필요없는데, 이미지피커가 편집기능도 제공하는놈이여서 걍 넣었습니다)

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

     

    그리고, 이미지피커에서 쓰는 액티비티도 등록해줍니다.

     

    <activity android:name="com.yongchun.library.view.ImageSelectorActivity"/>
    <activity android:name="com.yongchun.library.view.ImagePreviewActivity"/>
    <activity android:name="com.yongchun.library.view.ImageCropActivity"/>
    

     

    5) 필요한 파일 집어넣기

        - Classifier.java 와 TensorflowImageClassifier.java 파일은 데모 앱에서 복사해서 그대로 붙여넣기 해줍니다. (그래프파일 로딩과 inference - 인식- 기능을 사용)

        - 메뉴에서 Files > Folder > Assets Folder 를 선택하여 assets 폴더를 만들어준 후, 학습해서 만들어놓았던 그래프파일과 라벨파일을 복사해 넣어줍니다.

          (앞에서 만든 것 중에 memory mapped graph 는 안드로이드에서 아직 읽을 수 없으므로, quantized graph 나 optimized graph를 사용합니다)

     

    6) Main Activity 

     

    우선 Main Activity 에 View.OnClickListener 를 implement 해주고, 필요한 변수를 설정합니다.

     

    public class MainActivity extends AppCompatActivity implements View.OnClickListener {
        private static final int INPUT_SIZE = 299;
        private static final int IMAGE_MEAN = 0;
        private static final float IMAGE_STD = 255.0f;
        private static final String INPUT_NAME = "Mul";
        private static final String OUTPUT_NAME = "final_result";
    
        private static final String MODEL_FILE = "file:///android_asset/rounded_graph.pb";
        private static final String LABEL_FILE =
                "file:///android_asset/retrained_labels.txt";
    
        private Classifier classifier;
        private Executor executor = Executors.newSingleThreadExecutor();
    
        private CameraView cameraView;
        private TextView txtResult;
        private ImageView imgResult;
        private Button btnDetect;

     

    INPUT_SIZE 는 인식시킬 이미지 사이즈입니다.

    INPUT_SIZE, IMAGE_MEAN, IMAGE_STD 값은, 학습시킬 때 label_image 에서 분석시 사용했던 값을 그대로 사용합니다. (소스를 찾아보시면 나옵니다만, 일단 이 값으로)

    나중에 정확도 향상을 위해 MEAN값과 STD값은 조정할 수 있습니다.

    MODEL_FILE 값과 LABEL_FILE값은 assets 폴더에 복사해넣은 그래프파일과 라벨파일의 경로를 입력해주시면 됩니다.

    Classifier는 이미지인식을 위한 핵심클래스고요...

    Executor 는 그래프파일이 덩치가 크니까 로딩을 쓰레드로 돌리기 위해 사용합니다.

    나머지는 전부 레이아웃에서 정의한 뷰와 버튼들이네요.

     

     

    그리고,  onCreate 에서 각종 변수를 정의하고 초기화 해줍니다.

     

    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    
        cameraView = (CameraView)findViewById(R.id.cameraView);
        txtResult = (TextView)findViewById(R.id.txtResult);
        imgResult = (ImageView)findViewById(R.id.imgResult);
    
        Button btnGallery = (Button) findViewById(R.id.btnGallery);
        Button btnURL = (Button) findViewById(R.id.btnUrl);
        Button btnCamera = (Button) findViewById(R.id.btnCamera);
    
        btnDetect = (Button)findViewById(R.id.btnDetect);
    
        cameraView.setVisibility(View.INVISIBLE);   // 처음엔 카메라뷰를 숨겨놨다가, 카메라버튼 누를때 보여야 하니까 숨깁니다.
        btnDetect.setVisibility(View.INVISIBLE);       // 카메라 디텍트버튼도 숨겨놓습니다.
    
       // 각종 버튼들의 클릭이벤트 정의
        btnGallery.setOnClickListener(this);     
        btnURL.setOnClickListener(this);
        btnCamera.setOnClickListener(this);
        btnDetect.setOnClickListener(this);
    
       //텐서플로우 초기화 및 그래프파일 메모리에 탑재 
        initTensorFlowAndLoadModel();
    
       // 각종 권한체크 (외부라이브러리 이용)
        Dexter.withActivity(this)
                .withPermissions(
                        Manifest.permission.READ_EXTERNAL_STORAGE,
                        Manifest.permission.WRITE_EXTERNAL_STORAGE,
                        Manifest.permission.CAMERA
                ).withListener(new MultiplePermissionsListener() {
            @Override public void onPermissionsChecked(MultiplePermissionsReport report) {/* ... */}
            @Override public void onPermissionRationaleShouldBeShown(List<PermissionRequest> permissions, PermissionToken token) {/* ... */}
        }).check();
    
        // 카메라뷰는 별도로 권한체크메서드 제공해서 일단 넣어놨음
        cameraView.setPermissions(CameraKit.Constants.PERMISSIONS_PICTURE);
    
       // 카메라로 찍은 이미지를 텐서플로우에 보내서 이미지 인식
        cameraView.setCameraListener(new CameraListener() {
            @Override
            public void onPictureTaken(byte[] picture) {
                super.onPictureTaken(picture);
    
                Bitmap bitmap = BitmapFactory.decodeByteArray(picture, 0, picture.length);
    
                recognize_bitmap(bitmap);
            }
        });
    
    
    }

     

     

    텐서플로우 초기화시키는 메서드를 추가합니다.

    private void initTensorFlowAndLoadModel() {
        executor.execute(new Runnable() {
            @Override
            public void run() {
                try {
                    classifier = TensorFlowImageClassifier.create(
                            getAssets(),
                            MODEL_FILE,
                            LABEL_FILE,
                            INPUT_SIZE,
                            IMAGE_MEAN,
                            IMAGE_STD,
                            INPUT_NAME,
                            OUTPUT_NAME);
                } catch (final Exception e) {
                    throw new RuntimeException("Error initializing TensorFlow!", e);
                }
            }
        });
    }
    

     

     

    그리고, 각종 버튼 눌렀을때의 액션을 정의해줍니다.

     

    @Override
    public void onClick(View v) {
        // define which methods to call when buttons in view clicked
        int id = v.getId();
    
        switch(id) {
            case R.id.btnGallery:
                LoadImageFromGallery();   // Gallery button clicked
                break;
            case R.id.btnUrl:
                LoadImageFromUrl();    // Url button clicked
                break;
            case R.id.btnCamera:
                DetectImageFromCamera();  // Camera button clicked
                break;
            case R.id.btnDetect:
                cameraView.captureImage();   // detect button clicked (when cameraview is visible)
                break;
            default:
                break;
        }
    }

     

    위에서 적어놓은 메서드들을 정의해야겠지요...

     

    우선은 Gallery 버튼 클릭했을때 메서드 

     

    // 사진함에서 이미지 가져오기
    private void LoadImageFromGallery() {
    
        // cameraview와 detect버튼이 숨김처리되어있도록 확인
        cameraView.setVisibility(View.INVISIBLE);
        btnDetect.setVisibility(View.INVISIBLE);
        cameraView.stop();
    
        // 이미지를 한장만 선택하도록 이미지피커 실행
        ImageSelectorActivity.start(MainActivity.this, 1, ImageSelectorActivity.MODE_SINGLE, false,false,false);
    }
    
    // 가져온 이미지를 텐서플로우로 넘기기
    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        // 이미지피커에서 선택된 이미지를 텐서플로우로 넘깁니다.
        // 이미지피커는 ArrayList 로 값을 리턴합니다.
    
        if(resultCode == RESULT_OK && requestCode == ImageSelectorActivity.REQUEST_IMAGE){
            ArrayList<String> images = (ArrayList<String>) data.getSerializableExtra(ImageSelectorActivity.REQUEST_OUTPUT);
    
            // 이미지는 안드로이드용 텐서플로우가 인식할 수 있는 포맷인 비트맵으로 변환해서 텐서플로우에 넘깁니다
            Bitmap bitmap = BitmapFactory.decodeFile(images.get(0));
    
            recognize_bitmap(bitmap);
        }
    }
    

     

     

    다음은 Url 버튼 클릭했을때 다이얼로그 표시하고 url 입력받아서 텐서플로우에 넘기는 메서드입니다.

    앗 layout.dialog_prompt_url.xml 레이아웃 하나 만드는걸 앞에서 깜빡했네요...(간단하게 리니어레이아웃으로 입력받을 EditText 들어가 있는게 답니다. 따로 설명은 생략할게여...)

     

    // 다이얼로그창에서 URL 텍스트값으로 입력받아 텐서플로우에 넘김
    private void LoadImageFromUrl() {
        // cameraview와 detect버튼이 숨김처리되어있도록 확인
        cameraView.setVisibility(View.INVISIBLE);
        btnDetect.setVisibility(View.INVISIBLE);
        cameraView.stop();
    
        // URL 입력받을 얼럿다이얼로그를 불러와서 빌더로 만듭니다 (레이아웃 미리 작성해놓아야함)
        LayoutInflater layoutinflater = LayoutInflater.from(this);
        View dialogView = layoutinflater.inflate(R.layout.dialog_prompt_url,null);
    
        final EditText editURL = (EditText)dialogView.findViewById(R.id.editURL);
    
        AlertDialog.Builder alertDialogBuilder = new AlertDialog.Builder(this);
    
        alertDialogBuilder.setView(dialogView)
                .setTitle("Enter/Paste url of image to recognize")
                .setPositiveButton("Ok",new DialogInterface.OnClickListener() {
    
                    public void onClick(DialogInterface dialog, int id) {
    
                        // 메인쓰레드에서 url로부터의 스트림을 받아올수가 없어서 executor로 쓰레드 따로 만들어 작업
                       // 받은 스트림데이터를 비트맵으로 변환
    
                        executor.execute(new Runnable() {
                            @Override
                            public void run() {
                                try {
                                    String url = editURL.getText().toString();
                                    InputStream input = new java.net.URL(editURL.getText().toString()).openStream();
                                    //InputStream input = new java.net.URL(editURL.getText().toString()).openConnection().getInputStream();
    
                                        final Bitmap bitmap =  BitmapFactory.decodeStream(input);
                                    // 텐서플로우 인식 후 imgResult, txtResult 에 썸네일과 결과값을 표시해야 하는데,
                                    // 다른쓰레드에서 메인쓰레드 UI변경을 바로 못하니까 runOnUiThread 에서 실행해줌
                                        runOnUiThread(new Runnable() {
                                            @Override
                                            public void run() {
                                                //
                                                recognize_bitmap(bitmap);
                                            }
                                        });
    
    
                                } catch (IOException e) {
                                    e.printStackTrace();
                                }
    
                            }
                        });
    
                    }
                })
                .setNegativeButton("CANCEL",new DialogInterface.OnClickListener() {
    
                    public void onClick(DialogInterface dialog, int id) {
    
                    }
                })
                .create()
                .show();
    }
    

     

     

    camera버튼 눌렀을때, 카메라뷰 보이게 만들고, 카메라프리뷰 실행 - 외부라이브러리 쓰니까 완전 간단하네요!

    private void DetectImageFromCamera(){
        // cameraview 와 detect 버튼 보이기
        cameraView.setVisibility(View.VISIBLE);
        btnDetect.setVisibility(View.VISIBLE);
       // camera 프리뷰 실행
        if(!cameraView.isActivated())cameraView.start();
    }

     

     

     

    대부분 다 됐고, 이제 실제 비트맵 이미지를 입력받아서 그 결과값을 뷰에 뿌려주는 로직

     

    //비트맵 인식 및 결과표시
        private void recognize_bitmap(Bitmap bitmap) {
    
            // 비트맵을 처음에 정의된 INPUT SIZE에 맞춰 스케일링 (상의 왜곡이 일어날수 있는데, 이건 나중에 따로 설명할게요)
            bitmap = Bitmap.createScaledBitmap(bitmap, INPUT_SIZE, INPUT_SIZE, false);
    
    // classifier 의 recognizeImage 부분이 실제 inference 를 호출해서 인식작업을 하는 부분입니다.
            final List<Classifier.Recognition> results = classifier.recognizeImage(bitmap);
      // 결과값은 Classifier.Recognition 구조로 리턴되는데, 원래는 여기서 결과값을 배열로 추출가능하지만,
      // 여기서는 간단하게 그냥 통째로 txtResult에 뿌려줍니다.
      // imgResult에는 분석에 사용된 비트맵을 뿌려줍니다. 
            imgResult.setImageBitmap(bitmap);
            txtResult.setText(results.toString());
        }

     

     

    여기까지 하셨으면 빌드해보시고, 에러없으면 바로 실행됩니다...^^

     

     

    몇가지 빼먹은 부분이 있는것 같은데, 일단 올려둡니다.

     

    다음시간에는 아이폰으로 데모앱 돌려보기를 진행하겠습니다. (이 부분은 제가 iOS쪽 안해본지가 너무 오래되서 그냥 데모 앱 돌리는 정도 수준이 될겁니다 ㅎㅎ)

     

    잘 안되거나 궁금한거 있으시면 댓글남겨주시고 ,

     

    튜토리얼에 설명한 소스는 깃헙에 올릴텐데, 소스 조금 더 정리하고 주석도 좀 달고 해서 올릴거라 약간 시간이 걸릴듯 합니다.

    만약 빨리 소스파일이 필요하신 분 계시면 글 남겨주세요. 자료실에 압축해서 올려두겠습니다.

    (단, 자료실은 회원만 다운로드가능합니다 ㅠㅜ 죄송. 사비로 구글클라우드에서 돌리는 거라 그냥 열어두면 돈 많이 나갈거가 걱정돼서요...)