Android에서 YOLO5 돌려보기 — 2편
이 글은 1편에서 이어지는 글입니다. YOLO5 예제 앱의 기본적인 설명은 여기에서 확인할 수 있습니다.
1편에선 YOLO5 모델을 안드로이드에서 실행하고 싶지만 안드로이드를 잘 모르는 분을 위해 기본적인 내용을 다뤘습니다. 2편에서는 이런 기본적인 토대 위에 어떻게 이미지를 분석해서 객체 탐지를 하는지 보도록 하겠습니다. 되도록이면 파이토치와 YOLO5 관련된 부분에 집중해서 설명하도록 하겠습니다.
MainActivity
제일 먼저 onCreate() 메소드를 살펴보겠습니다. onCreate() 메소드는 안드로이드에서 화면이 뜨게 되면 실행되는 메소드 중 하나인데, 액티비티 클래스를 분석할 때 제일 먼저 보는 메소드 중 하나라고 생각하시면 됩니다. 권한 설정 점검에서부터 각종 변수나 화면 구성요소인 버튼, 뷰 등을 초기화 시켜줍니다. 굉장히 긴 메소드라 중요한 부분만 살펴보도록 하겠습니다.

먼저 버튼 클릭 이벤트들을 처리하는 코드들이 보이는군요. 위의 코드는 메인 화면 왼쪽 아래의 코드를 눌렀을 때 수행되는 코드입니다. 클릭 이벤트를 처리하기 위해 버튼에 setOnClickListener()를 이용해서 클릭 리스너를 달아줍니다. 버튼 초기 텍스트는 setText() 메소드를 이용해 ‘Test Image 1/3’ 으로 설정했고, 클릭할 때 마다 mImageIndex을 바꿔 가며 버튼의 문구와 화면에 표시할 이미지를 변경해 줍니다.
그 다음으론 메인 화면 하단 중앙의 Select 버튼을 눌렀을 때의 동작을 보겠습니다. 이 버튼을 누르면 다이얼로그를 띄우는 코드가 수행이 되는데, 대략적으로 다이얼로그를 띄우기 위한 코드들이라고 보시면 됩니다. 다이얼로그에는 세 가지 선택지가 있는데, 갤러리에서 이미지를 가져올 수도 있고, 카메라로 사진을 찍어서 이미지를 가져올 수도 있습니다. 제일 중요한 부분의 코드만 보겠습니다.
builder.setItems(options, new DialogInterface.OnClickListener() {
@Override
public void onClick(DialogInterface dialog, int item) {
if (options[item].equals("Take Picture")) {
Intent takePicture = new Intent(android.provider.MediaStore.ACTION_IMAGE_CAPTURE);
startActivityForResult(takePicture, 0);
}
else if (options[item].equals("Choose from Photos")) {
Intent pickPhoto = new Intent(Intent.ACTION_PICK, android.provider.MediaStore.Images.Media.INTERNAL_CONTENT_URI);
startActivityForResult(pickPhoto , 1);
}
else if (options[item].equals("Cancel")) {
dialog.dismiss();
}
}
});
사용자가 객체 탐지를 위해 카메라나 갤러리를 선택하면 실행되는 코드입니다. 여기에서 호출하는 startActivityForResult() 메소드는 다른 액티비티, 즉 다른 화면을 실행시키고 , 실행된 다른 화면이 종료될 때 이 메소드를 호출한 화면에서 사용할 결과값을 콜백 메소드인 onActivityResult()로 전달해 달라는 메소드입니다.
특히나 메소드에 전달하는 인자 중 제일 마지막 0, 1 이 숫자들을 자세히 볼 필요가 있는데요, 이 숫자들은 requestCode로서 어떤 화면에 어떤 요청을 했는지 구분을 하게 만드는 숫자들입니다. onActivityResult() 메소드에서 이 requestCode를 이용합니다. onActivityResult() 메소드는 전달받은 이미지를 화면에 표시해 주는 로직이 있는데, 안드로이드에 치중한 내용이라 한 번 읽어보시길 추천드리고 넘어가도록 하겠습니다. 여기에서는 다이얼로그의 메뉴를 클릭하면 Intent에 이런저런 값을 넣어서 화면을 실행한다 정도만 알아 두시고, 안드로이드에서 카메라나 갤러리 실행을 위해서는 이런 것들이 필요하구나 정도만 아셔도 무방합니다. 보통 0, 1 이렇게 하지 않고 FROM_GALLARY = 1 이렇게 상수를 정의한 다음 이런 상수를 쓰기도 한다는 점도 알아두세요.
한 가지 주목해야 할사항은 카메라나 갤러리에서 가져온 비트맵을 90도 회전 시키고 있습니다.
Matrix matrix = new Matrix();
matrix.postRotate(90.0f);
mBitmap = Bitmap.createBitmap(mBitmap, 0, 0, mBitmap.getWidth(), mBitmap.getHeight(), matrix, true);
카메라나 갤러리에서 가져온 사진은 수평으로 놓여진 이미지라 이렇게 회전을 시킨다고 보시면 됩니다. 각도를 조절해 보면서 모델의 분석 결과가 어떻게 달라지는지 보는 것도 재미가 될 수 있겠군요 :)
화면 우하단의 Live 버튼은 단순히 ObjectDetectionActivity를 실행시킵니다. 단순한 코드라 따로 설명이 필요할 것 같진 않습니다. 마지막으로 메인 화면의 중앙에 떠 있는 사진에서 객체 탐지를 수행하는 버튼인 Detect 버튼 동작을 보겠습니다.

이미지 분석중임을 나타내기 위한 프로그레스바를 띄워주고, 화면에 떠 있는 mBitmap을 모델에서 사용할 수 있도록 이미지 비율 관련 변수를 초기화 해 줍니다. 이미지 크기에 따라 x축이나 y축으로 늘이거나 줄여야 될 수 있기 때문에 여기에서 이런 변수를 조절해 줍니다. 그 후에 백그라운드에서 이미지 분석을 해서 객체 탐지를 할 수 있도록 쓰레드 객체를 생성 및 실행합니다. MainActivity가 Runnable을 구현하고 있어서 쓰레드 생성할 때 인자로 전달한다고 생각하시면 됩니다.

이제 마지막으로 파일에서 모델을 읽어들입니다. classes.txt 파일은 모델에서 분류 가능한 객체들의 분류(class)가 표기되어 있습니다. 이 부분은 프로젝트의 README에도 설명이 나와 있는 부분이니 거기도 참고하세요.

분류할 항목들을 파일에서 읽어들인 다음 PrePostProcessor에 String 문자열 형태로 저장을 하는 것으로 객체 탐지 준비가 끝이 납니다.
그럼 이제 객체 탐지를 하는 곳을 살펴볼 차례입니다. 모델 파일을 읽는 assetFilePath() 메소드를 보겠습니다.

이 메소드를 사용한 곳은 앞서 설명한 onCreate()의 제일 아래쪽입니다. assets에 있는 모델인 ‘yolo5s.torchscript.ptl’을 파일 객체로 읽어들인 다음 그 파일의 절대경로를 이용해 파이토치의 Module 객체를 만듭니다.
mModule = LiteModuleLoader.load(MainActivity.assetFilePath(getApplicationContext(), "yolov5s.torchscript.ptl"));
BufferedReader br = new BufferedReader(new InputStreamReader(getAssets().open("classes.txt")));
이렇게 생성한 모듈 객체를 이용해서 이미지에서 객체를 탐지하는 거죠. 조금 더 많은 예제를 확인하고 싶다면 여기도 참고하세요.
참고로 이 메소드는 ObjectDetectionActivity에서도 사용하기 위해 MainActivity에 static으로 선언이 되어 있습니다. 예제라서 용인될 구조입니다… 깔끔하진 않군요.
이제 읽어들인 모델을 이용해서 객체를 탐지하는 run() 메소드를 살펴보겠습니다. 자바에 익숙한 분들은 아시겠지만 Runnable 인터페이스에서 정의한 메소드로서, Select 버튼을 눌렀을 때 생성한 쓰레드에서 이미지 분석을 처리할 로직을 run() 메소드 내부에 정의해 두었습니다.

이미지를 처리하는 순서가 약간은 복잡해 보입니다만, 대체적인 흐름은
이미지 → 텐서 → 모델 → 추론 결과 → 결과값을 텐서로 변환 → 텐서에서 float형 배열로 변환 → 배열에서 각각의 객체 좌표 추출
이런 순서입니다. 파이썬에서 이미지를 처리하는 것과 크게 다르지 않죠.
먼저 비트맵 이미지의 크기를 조절합니다. 학습시켜 놓은 모델의 인풋으로 받을 수 있는 이미지의 크기에 맞추는 작업입니다. PrePostProcessor 클래스에서 전처리나 후처리에 필요한 각종 값을 확인할 수 있는데, 여기에선 640 x 640 크기의 비트맵을 인풋 데이터로 받습니다. 그 다음 변환된 비트맵을 모델에 입력하도록 텐서로 변환합니다. 이렇게 텐서로 변환된 이미지는 좀 전에 읽어들인 모듈에 forward() 메소드로 전달되어 객체 탐지를 수행합니다.
IValue[] outputTuple = mModule.forward(IValue.from(inputTensor)).toTuple();
final Tensor outputTensor = outputTuple[0].toTensor();
final float[] outputs = outputTensor.getDataAsFloatArray();
final ArrayList<Result> results = PrePostProcessor.outputsToNMSPredictions(outputs,
mImgScaleX, mImgScaleY, mIvScaleX, mIvScaleY, mStartX, mStartY);
배열에서 결과값을 좌표로 추출하는 부분은 PrePostProcessor에서 처리합니다. 모델에서 중첩된 객체 좌표들까지 반환하기 때문에 outputsToNMSPredictions() 메소드에서 임계점 이하의 결과는 제거하면서 그 중에 제일 높은 점수를 획득한, 제일 정확한 객체의 테두리만 남깁니다. 비-최대 억제 알고리즘(NMS)을 사용해서 이러한 일을 수행하는데요, 자세한 설명은 여기에서 확인하실 수 있습니다. 다른 YOLO 프로젝트의 코드를 자바로 포팅했는데, 코드가 지저분해서 쉽사리 읽혀지진 않네요. 예제 코드인 점을 감안해서 보시고, 단순히 YOLO 모델뿐만 아니라 여러 알고리즘도 사용하고 있고, 이런 것들도 이해를 해야 효율적으로 모델을 사용할 수 있다 정도만 알아두시면 됩니다.
객체인지 아닌지 판단하는 임계점 값인 mThreshold을 0.30f로 설정을 해 놓았는데, 값을 조절해 가며 다양하게 시도를 해 보는 것도 재미있을 것 같네요.
run() 메소드의 마지막 부분은 화면을 갱신하는 부분입니다.
runOnUiThread(() -> {
mButtonDetect.setEnabled(true);
mButtonDetect.setText(getString(R.string.detect));
mProgressBar.setVisibility(ProgressBar.INVISIBLE);
mResultView.setResults(results);
mResultView.invalidate();
mResultView.setVisibility(View.VISIBLE);
});
안드로이드는 main 쓰레드에서만 화면 갱신을 할 수 있기 때문에 main 쓰레드에서 동작하는 runOnUiThread() 메소드에 화면 갱신 로직을 전달하고 있습니다. Runnable 인터페이스를 인자로 받기 때문에 람다 형식으로 화면 처리 코드를 전달한다 정도로 생각하시면 됩니다. MainActivity에서 정의한 run() 메소드이기에 MainActivity의 객체를 전달하지는 못하는군요..
ResultView
메인 화면에 떠 있는 사진에서 객체 탐지를 수행한 결과를 붉은 색 네모 칸으로 표시하고 어떤 클래스에 속해 있는지 표시하는 뷰 입니다. 메인 화면에서 결과값 표시를 위해 객체들의 좌표를 받아 표시한다 정도만 알아 두시면 됩니다. 뷰에 사각형을 어떻게 그리는지, 글자를 어떻게 쓰는지 궁금하다면 어려운 코드는 없기에 한번 읽어보시면 됩니다.
ObjectDetectionActivity
1편에서 살펴봤던 AbstractCameraXActivity를 상속받는 액티비티입니다. 이 액티비티는 카메라로 읽어들인 이미지에서 실시간으로 객체 탐지를 수행합니다.
먼저 클래스에서 선언한 변수부터 살펴 보겠습니다.

파이토치 모델을 담고 객체 탐지를 할 모듈, 객체 탐지 결과를 화면에 표시하기 위한 ResultView, 탐지 결과를 저정하는 AnalysisResult 클래스가 있습니다. Result 클래스는 PrePostProcessor.java 내부에 있는 클래스로 탐지한 객체의 분류와 스코어, 좌표를 가지고 있습니다.
class Result {
int classIndex;
Float score;
Rect rect;
public Result(int cls, Float output, Rect rect) {
this.classIndex = cls;
this.score = output;
this.rect = rect;
}
}
클래스 구조는 그렇게 깔끔하지 않습니다. 예제임을 감안하면서 봐 주세요.
그 다음으로는 getCameraPreviewTextureView() 메소드를 살펴보겠습니다. AbstractCameraXActivity에서 정의된 메소드로, 카메라에서 받아온 영상을 어느 뷰에 표시할 것인지 반환하고 있습니다.
@Override
protected TextureView getCameraPreviewTextureView() {
mResultView = findViewById(R.id.resultView);
return ((ViewStub) findViewById(R.id.object_detection_texture_view_stub))
.inflate()
.findViewById(R.id.object_detection_texture_view);
}
먼저 객체 탐지 결과를 표시하기 위한 mResultView 변수를 초기화 해 주고, 그 다음으로는 카메라에서 실시간으로 표시를 해 주기 위한 TextureView를 반환하고 있습니다. object_detection_texture_view.xml 화면 내에 있는 texture_view.xml을 가져오기 위한 코드라고 보시면 됩니다.
안드로이드에서는 특정 뷰나 레이아웃을 가져와서 inflate() 메소드를 호출해서 메모리에 그 화면을 객체화시켜 조작을 합니다. 즉, object_detection_texture_view_stub로 등록된 뷰를 가져와서 객체화, 실체화, 메모리에 로딩한 뒤에 그 뷰 내부에서 id가 object_detection_texture_view인 뷰를 또다시 가져온다 정도로 생각하시면 됩니다.
객체 탐지 결과를 화면에 표시하는 applyToUiAnalyzeImageResult() 메소드는 화면에 결과를 표시하는 처리만 하는군요. 결과값을 ResultView에 넣어주고 새롭게 저장된 결과값을 화면에 그려주도록 invalidate()를 호출합니다. invalidate() 메소드는 특정 뷰의 위치나 색상같은 내용이 변경되어 화면 갱신이 필요한 경우, 변경된 값을 기반으로 뷰를 화면에 다시 화면에 그려주는 메소드입니다.
@Override
protected void applyToUiAnalyzeImageResult(AnalysisResult result) {
mResultView.setResults(result.mResults);
mResultView.invalidate();
}
이 메소드는 실시간 객체 탐지를 위한 화면 분석이 끝나면 호출이 됩니다. ObjectDetectionActivity의 부모 클래스인 AbstractCameraXActivity 클래스의 imageAnalysis 콜백 선언부를 참고해 주세요.
imageAnalysis.setAnalyzer((image, rotationDegrees) -> {
if (SystemClock.elapsedRealtime() - mLastAnalysisResultTime < 500) {
return;
}
final R result = analyzeImage(image, rotationDegrees);
if (result != null) {
mLastAnalysisResultTime = SystemClock.elapsedRealtime();
runOnUiThread(() -> applyToUiAnalyzeImageResult(result));
}
});
부모 클래스에서 화면 분석이 끝날 때마다 결과 화면을 표시하도록 로직은 마련해 두고, 구체적으로 어떻게 화면에 표시할 것인지는 자식 클래스에서 정하게 해 두었습니다. 템플릿 메소드 패턴이라고 생각하시면 되겠습니다.
이제 이미지 분석 메소드인 analyzeImage() 메소드를 살펴볼 차례입니다. 이 메소드에서는 객체 탐지를 할 타겟 이미지를 비트맵으로 가져오는데, 비트맵으로 가져오기 위한 처리를 하는 imgToBitmap() 메소드 먼저 살펴보시죠.

메인 화면에서는 이미지를 비트맵으로 가져오기 위해 BitMapFactory나 Bitmap 객체에서 지원하는 메소드들을 바로 사용했습니다만, 여기에서는 중간 단계를 조금 더 거치고 있습니다. 실시간으로 받아오는 이미지가 ImageProxy 형태로 되어 있고, 여기에서 Image 객체를 받아온 이후 객체 탐지 모델에 입력할 수 있는 비트맵 형태로 변환해 준다고 생각하시면 됩니다. 상당히 복잡한 코드인데, 인공지능 등의 이미지 처리를 위한 Image 객체에서 필요한 정보를 배열로 가져온 다음 비트맵으로 다시 조립하는 로직입니다. 이미지를 화면에 그려주는 용도가 아닌지라 배열 등의 로우 데이터를 직접 조작하는 로직이라고 보시면 됩니다.
NV21 포맷의 이미지로 만든 다음에 비트맵으로 다시 변환해 주고 있는데, 이 변환 코드는 여기에서 가져온 것 코드이기에 왜 이렇게 변환했는지는 해당 링크 참고해 주세요. camera x 관련 내용 때문에 어려울 수도 있는데, 제품을 만드는 것이 아닌 예제 프로젝트 제작이나 특정 기능을 구현하기 위해 인터넷에서 코드를 가져올 수도 있다는 것도 알아두세요. 남이 만들어둔 코드를 잘 활용하는 것도 도움이 됩니다.
이제 마지막으로 analyzeImage() 메소드를 살펴보겠습니다. 앞부분에는 YOLO5 모델을 가져오고, 만약에 없으면 예외를 발생시키고 있습니다. MainActivity에서 모델을 초기화 하는데 공용으로 사용하는 처리를 했으면 어떨까 싶기도 하네요.
try {
if (mModule == null) {
mModule = LiteModuleLoader.load(MainActivity.assetFilePath(getApplicationContext(), "yolov5s.torchscript.ptl"));
}
} catch (IOException e) {
Log.e("Object Detection", "Error reading assets", e);
return null;
}
그 이후엔 비트맵을 모델이 받을 수 있도록 처리하고 있습니다.
Bitmap bitmap = imgToBitmap(image.getImage());
Matrix matrix = new Matrix();
matrix.postRotate(90.0f);
bitmap = Bitmap.createBitmap(bitmap, 0, 0, bitmap.getWidth(), bitmap.getHeight(), matrix, true);
Bitmap resizedBitmap = Bitmap.createScaledBitmap(bitmap, PrePostProcessor.mInputWidth, PrePostProcessor.mInputHeight, true);
final Tensor inputTensor = TensorImageUtils.bitmapToFloat32Tensor(resizedBitmap, PrePostProcessor.NO_MEAN_RGB, PrePostProcessor.NO_STD_RGB);
IValue[] outputTuple = mModule.forward(IValue.from(inputTensor)).toTuple();
final Tensor outputTensor = outputTuple[0].toTensor();
final float[] outputs = outputTensor.getDataAsFloatArray();
앞서 나온 MainActivity와 거의 동일한 코드를 사용하네요. 이미지를 가져와서 모델에 입력시키기 위해 텐서로 만들고, forward() 메소드를 이용해 모델에서 객체 탐지를 수행합니다. 카메라에서 가져온 이미지를 처리하기 위해 앞서 설명한 imgToBitmap()메소드를 사용하여 비트맵을 만들어 주는 것만 주의해 주세요.
마지막으로 NMS를 이용해서 결과값을 나타내고 있습니다.
float imgScaleX = (float)bitmap.getWidth() / PrePostProcessor.mInputWidth;
float imgScaleY = (float)bitmap.getHeight() / PrePostProcessor.mInputHeight;
float ivScaleX = (float)mResultView.getWidth() / bitmap.getWidth();
float ivScaleY = (float)mResultView.getHeight() / bitmap.getHeight();
final ArrayList<Result> results = PrePostProcessor.outputsToNMSPredictions(outputs, imgScaleX, imgScaleY, ivScaleX, ivScaleY, 0, 0);
return new AnalysisResult(results);
이렇게 생성된 결과값 AnalysisResult은 AbstractCameraXActivity에서 applyToUiAnalyzeImageResult() 메소드로 전달되어 화면을 갱신하게 됩니다.
final R result = analyzeImage(image, rotationDegrees);
if (result != null) {
mLastAnalysisResultTime = SystemClock.elapsedRealtime();
runOnUiThread(() -> applyToUiAnalyzeImageResult(result));
}
전체적으로 까다로운 부분이 군데군데 보이는 예제군요. 객체 탐지를 위한 이미지를 비트맵으로 변환해서 입력하는 부분은 android x의 이미지 처리 관련 지식이 필요하고, 모델에서 출력한 결과 중 제일 정확한 객체의 테두리를 구하기 위한 NMS는 객체 탐지 모델이 어떻게 구성되어 있는지 알아야 하면서 안드로이드에서 직접 구현해야 되는 부분이기도 합니다. 한마디로 말하자면 안드로이드와 인공지능 지식이 둘 다 어느 정도 있어야 완벽히 이해가 되는 예제로군요.
그럼에도 모델이 어떻게 동작하는지 대략적인 흐름을 확인하기엔 좋은 예제인 것 같습니다. 군데군데 어려운 부분이 있지만, 이런 것들이 실제로 모델을 어떻게 동작시킬 것인지에 대해 더 깊은 이해를 할 수 있는 부분입니다. 실무에서도 이런 것들을 어떻게 처리하느냐에 따라 서비스의 퀄리티가 엄청나게 차이나기도 합니다만, 예제 코드 분석이니만큼 가벼운 마음으로 접근하는 것도 좋습니다.