안드로이드앱에서 Image Segmentation 모델을 사용하는 프로젝트를 진행하게 되었다.
<요구 조건>
아래 사진처럼 사람의 외곽선만을 화면에 표시
사람/배경에 대한 투명도 조절
사용자가 직접 화면에 표시할 외곽선을 선택할 있도록 하게 하기 위해서 사람 객체 각각에 대한 외곽선 객체를 따로 생성
처음엔 조건을 충족시켜주면서 안정적이고 높은 성능을 보이는 yolov5 모델을 선택하였는데 파이토치를 사용하는 파이썬으로 작성된 모델이라서 파이참에서 돌리면 충돌이나 에러가 심해 구글 코랩을 사용해 테스트와 코드 커스텀을 진행하였다.
그리고 이걸 앱에서 사용할 수 있도록 하기 위해 앱 내장 vs 서버향 이라는 방법이 있었으나 서버 개발자가 무거운 모델을 서버에 올려 다루는 것에 익숙치 않아서 앱에 내장하는 방법을 어떻게든 고민해봐야 하는 상황이었다.
첫번째 시도. yolov5 모델 .tflite 로 변환해서 앱 assets 파일에 넣기
이전에 이미지 분류 모델을 개발해서 앱에 내장한 경험이 있는데 모델을 .tflite 형태로 변환해 앱 assets 파일에 넣고 모델을 load 해서 사용했었기 때문에 이번에도 유사한 방식으로 하면 될 것이라고 생각했다.
yolov5 오픈소스 export.py 파일에 모델을 변환해주는 코드가 들어있기 때문에 손쉽게 변환에 성공했고 앱에 내장해서 결과를 출력했다.
!python export.py --weights yolov5s-seg.pt --include='tflite' # --nms
그런데 output 을 화면에 그려보니 아래와 같았고..... shape도 (1, 15000,32) 인가로 뭔가 수상했다. 알고보니 NMS 알고리즘이 적용되지 않은 상태였고 NMS를 적용하지 않으면 하나의 객체애 대해 여러번 detection 하게 되어서 사진과 같이 결과가 마구 겹쳐져 보이는 것이다. 그래서 여러 detection 결과중 가장 확률이 높은 1개만 선택해주는 것이 NMS 알고리즘이다.
nms 알고리즘을 적용해서 모델을 .tlite로 변환하고 싶다면 --nms 옵션만 추가해주면 된다.
그런데 이번에도 shape이 (1,100,4) 인가로 뭔가 수상했다. 난 사람에 대한 mask를 얻어야 하는데 output shape이 그 정보들을 다 담고 있는 것 같지 않은 듯했고 역시나 저 4라는 숫자는 bounding box의 네 꼭지점을 의미하는 것이었다.
즉, mask 는 내가 직접 추출해야 하고 이는 nms를 구현하기 전 결과와 nms 를 적용하여 유의미한 detection 결과만 뽑아낸 결과를 조합하여 mask 를 직접 만드는 코드를 짜야 한다.
그래서 nms 적용 전 모델을 앱에 넣고 첫번째 시도처럼 모델을 load해서 사용하되 output 결과를 가지고 직접 mask를 추출하는 코드와 nms 알고리즘 코드를 짜보려고 했으나 장렬하게 실패했다.
언젠가는 해보고 싶긴하지만 아마 이걸 내가 한다면 나는 모델 개발자 준전문가급의 실력인게 아닐까?... 가만 보면 지금까지 앱에서 yolov5 모델을 사용하는 예제도 거의 찾을볼 수 없어서 만약 내가 이걸 앱에서 쉽게 사용할 수 있도록 상용화 하는 코드를 작성한다면 미니 논문으로 낼 수 있지 않을까?
두번째 시도. Flask를 사용해 코랩에 접근해 모델 코드 돌리기
앱에 내장하는 방법이 너무 어려워서 포기하고 싶어지던 시점에서 안드로이드에서 코랩에 접근해 코드를 실행시키기고 결과를 얻어오는 방법은 없을지에 대해서 생각해봤다.
모든지 가능하다고 대답하는 gpt 신에게 물어본 결과 API 를 만들어 안드로이드에서 api를 요청해 yolov5 코드를 실행시키기는 프로세스를 거쳐야 하고 Flask 를 사용해서 쉽게 코드를 짤 수 있다고 한다. 난 백엔드 개발에 대한 지식이 별로 없기 때문에 코랩에 flask를 설치하고 파이썬으로 코드를 짜면 되겠다고 생각했다.
안드로이드에서 모델을 돌리기 위해 버튼 등을 클릭하면 api 가 요청을 기다리고 있다가 요청을 받고 코랩 코드를 실행시키는 것이다.
api 가 요청을 대기하고 있어야 하기 때문에 실제 앱을 상용화하려면 서버를 구축해야 하기 때문에 결국에는 백엔드 개발자의 몫이지만 일단 된다는 것을 증명하고 싶었기 때뭄에 local 에서 테스트 해보기로 했다.
아래 코드를 코랩에 작성하고 실행시키면 필요한 모듈을 import하고 api 요청을 기다리면서 run 상태가 된다.
그리고 앱에서 입력 데이터 (이미지)와 함께 해당 api를 호출하면 입력 데이터를 특정 경로에 저장하고 모델 코드가 실행되면서 입력데이터를 segmentation 한 다음 지정된 경로에 결과를 저장한다. 그리고 결과가 저장된 경로에 다시 접근하여 결과 이미지나 데이터를 가져오는 방식으로 코드를 짜려고 했다.
from flask import Flask, request, jsonify
import torch
import utils
display = utils.notebook_init() # checks
from utils.downloads import attempt_download
app = Flask(__name__)
# 예시로 간단한 딥러닝 모델 함수를 구현합니다.
def run_deep_learning_model(input_data):
# 모델 실행 로직을 여기에 작성합니다.
# 입력 데이터를 받아서 딥러닝 모델 실행 후 결과를 반환하는 함수입니다.
# 예를 들어, YOLOv5 모델을 사용한다면 여기에 모델 실행 코드를 작성합니다.
display = utils.notebook_init()
p5 = ['n', 's', 'm', 'l', 'x'] # P5 models
cls = [f'{x}-seg' for x in p5] # segmentation models
for x in cls:
attempt_download(f'/gdrive/My Drive/PHODO/Segmentation Model/Yolo_custom/yolov5/weights/yolov5{x}.pt')
# 모델 코드 실행
!python segment/predict.py --weights weights/yolov5x-seg.pt --source data/images/custom_test/trevi.jpg --name 'data/images/custom_result'
# 결과 -> 결과 데이터가 저장된 경로에 접근해 가져옴
# 결과를 가져오는 코드는 반드시 모델 코드 실행 결과가 나온 후에 실행되어야 하므로 동기처리 중요
result = "answer"
return result
@app.route('/predict', methods=['POST'])
def predict():
try:
# 안드로이드 애플리케이션에서 POST 요청을 보낼 때 JSON 형태의 데이터를 전송합니다.
# JSON 형식의 데이터를 받아와서 딥러닝 모델에 넣고 결과를 반환합니다.
data = request.json
input_data = data.get('input_data') # 안드로이드 애플리케이션에서 보낸 입력 데이터를 가져옵니다.
# 입력 데이터를 지정된 경로에 저장
# 딥러닝 모델 실행
output = run_deep_learning_model(input_data)
# 결과를 JSON 형식으로 반환
return jsonify(output)
except Exception as e:
return jsonify({"error": str(e)})
if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000)
안타깝지만 이 방법도 시도하다가 앱에서 해당 api 를 호출하기 위해 통신 코드 등을 짜는 것이 귀찮아서 다른 방법을 먼저 찾아보기로 하고 보류해두었다. 그래서 코드도 미완전한 상태이므로 섣불리 참고하지는 말고 그냥 아이디어 정도만 가져간다고 생각하길....
좀 귀찮고 비호율적으로 보이는 방법일 수 있지만 스프링 같은 프레임워크를 사용하는 백엔드 개발자가 아니라면 파이썬으로 쉽게 외부 모델 코드를 실행시키는 코드를 짤 수 있을 것 같아서 나름 획기적인 방법이라고 생각한다.
세번째 시도. 안드로이드에서 파이썬 코드 사용하기 (chaquo)
중요한건 chaquo 플러그인을 추가 / 파이썬 설치 / 필요한 requirements.txt 설치 / ABI 중복 되지 않도록 확인
파이썬에서 tensorflow, tensorflow-lite, pytorch 등을 사용한다면
그리고 python 파일을 작성할 폴더를 하나 생성한 다음 여기에 yolov5 오픈소스를 클론
plugins {
id 'com.chaquo.python'
}
android {
compileSdk 32
defaultConfig {
applicationId "com.example.phodo"
minSdk 29
targetSdk 32
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
ndk {
// 필요한 ABI만 지정하고 중복을 방지합니다.
abiFilters 'armeabi-v7a','armeabi', 'arm64-v8a'
//abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64', 'armeabi'
}
packagingOptions {
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'jni/arm64-v8a/libfbjni.so'
}
/* python install */
python {
version "3.8"
}
python {
pip {
// A requirement specifier, with or without a version number:
//install "scipy"
//install "requests==2.24.0"
//install "jdata"
// An sdist or wheel filename, relative to the project directory:
//install "MyPackage-1.2.3-py2.py3-none-any.whl"
// A directory containing a setup.py, relative to the project
// directory (must contain at least one slash):
//install "./MyPackage"
// "-r"` followed by a requirements filename, relative to the
// project directory:
//install "-r", "/Users/gangmini/AndroidStudioProjects/PHODO/app/src/main/python/yolov5/requirements.txt"
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
aaptOptions {
noCompress "tflite"
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = '1.8'
}
aaptOptions {
noCompress "tflite"
noCompress "lite"
}
viewBinding {
enabled = true // View Binding 사용을 위한 설정
}
sourceSets {
main {
//jniLibs.srcDirs = ['libs']
jni {
srcDirs 'src/main/jni', 'src/main/jniLibs/libs'
}
}
}
buildFeatures {
viewBinding true
}
}
dependencies {
implementation ('androidx.core:core:1.7.0') {
exclude group: 'com.android.support', module: 'support-compat'
}
implementation 'androidx.appcompat:appcompat:1.5.0'
implementation 'com.google.android.material:material:1.8.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation project(path: ':opencv')
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3'
implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.3.1' //2.6.1
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.3.1' //viewModelScope
implementation "androidx.fragment:fragment-ktx:1.3.3"
implementation 'com.google.android.datatransport:transport-runtime:3.1.9'
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
// Pytorch
implementation 'org.pytorch:pytorch_android_lite:1.13.1'
implementation 'org.pytorch:pytorch_android_torchvision_lite:1.13.1'
// OpenCV
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation files('libs/libDaumMapAndroid.jar')
}
혹시 에러가 나면 gradle.propertis에 아래 코드를 추가했는지 확인해보자
android.nonTransitiveRClass=true
android.enableJetifier=true
자바와 코틀린으로 개발하는 안드로이드에서 파이썬을 사용할 수 있다니...!! 너무 혁신적인 방법을 발견해서 굉장히 신났고 이 방법이 딱이라고 생각했지만... 안타깝게도 yolov5 requirements 에 있는 여러 라이브러리를 설치하는 과정에서 라이브러리 사이의 버전 충돌이 있었다. 예를 들어 matplot에 사용되는 numpy와 tensorflo에서 사용되는 numpy의 최소 버전이 맞지 않는다는등.... chaquo 에서 지원하는 python 버전과도 충돌이 일어나면서 발생하는 문제 같았다. 코랩에서는 문제 없이 잘
https://chaquo.com/chaquopy/doc/current/android.html
다섯번째 시도. DeepLabV3 모델로 바꾸기 -> Sementic 모델
더이상은 yolov5 모델을 앱에서 사용하는 것은 무리라고 판단했다. tflite 용으로 변환했을 때 지원하는 기능은 object detection 기능뿐이고 segmentation 기능은 지원하지 않는다. 모바일 환경에서 segmentation을 구현하고 싶으면 tensorflow 에서 제공하는 모델을 사용해볼 수도 있지만 성능이 별로여서 사용하고 싶지 않았다....
https://www.tensorflow.org/lite/examples/segmentation/overview?hl=ko
https://github.com/tensorflow/examples/tree/master/lite/examples/image_segmentation/android
학술논문 하나 발표할 생각으로 연구해볼 가치는 있다고 생각하지만 현재 내 실력으로 해볼 수 있는 선에서는 다 해봤다고 생각했다.
그래서 기존에 안드로이드 내장에 적합한 세그멘테이션 모델이 없는지 찾아보게 되었고 파이토치 튜토리얼에서 파이토치로 작성된 deeplabv3 라는 모델을 알게되었다. 아예 작정하고 모바일에서 파이토치를 사용하고 싶을 때 이 모델을 써보라고 가이드라인이 나와있었다. 처음에는 성능이 별로일 것 같아서 기대를 안 했는데 생각보다 나쁘지 않은 결과를 얻을 수 있었다
https://tutorials.pytorch.kr/beginner/deeplabv3_on_android.html
하지만 이 모델은 instance segmentation 모델이 아닌 sementic segmentation 모델이었다
sementic 모델은 인간은 인간끼리, 강아지는 강아지끼리 하나의 덩어리로 분류하고
instance 모델은 같은 인간이어도 모두 각각의 객체로 구분하게 된다
사실 나는 인물들을 각각 서로 다 다른 객체로 구분해서 외곽선을 각각 화면에 그리고 사용자가 원하는 외곽선 선택할 수 있도록 개발하고 싶었기 때문에 나에게 딱 들어맞는 모델은 아니다. 하지만 일단 졸작 제출 및 배포를 해보고 싶었고 모델 앱 내장 때문에 너무 지쳐서 일단 이 모델을 사용해보고 후처리로 보완해볼 생각이다.
/* deeplabv3 모델 실행 코드 */
try {
val batchNum = 0
val buf = contentResolver.openInputStream(data!!.data!!)
bitmap = BitmapFactory.decodeStream(buf)
buf!!.close()
//이미지 뷰에 선택한 사진 띄우기
binding.imageView.setScaleType(ImageView.ScaleType.FIT_CENTER)
binding.imageView.setImageBitmap(bitmap)
} catch (e: IOException) {
e.printStackTrace()
}
try {
mModule = LiteModuleLoader.load(assetFilePath(getApplicationContext(), "deeplabv3_scripted_optimized.ptl"))
} catch (e: IOException) {
Log.e("ImageSegmentation", "Error reading assets", e)
finish()
}
/* 결과를 화면에 표시 (외곽선만 표시) */
val thread1 = object : Thread() {
override fun run() {
super.run()
val inputTensor: Tensor = TensorImageUtils.bitmapToFloat32Tensor(
bitmap,
TensorImageUtils.TORCHVISION_NORM_MEAN_RGB,
TensorImageUtils.TORCHVISION_NORM_STD_RGB
)
val inputs = inputTensor.dataAsFloatArray
val outTensors = mModule!!.forward(IValue.from(inputTensor)).toDictStringKey()
val outputTensor: Tensor = outTensors["out"]!!.toTensor()
val result = outputTensor.dataAsFloatArray
val width: Int = bitmap!!.getWidth()
val height: Int = bitmap!!.getHeight()
val intValues = IntArray(width * height)
for (j in 0 until height) {
for (k in 0 until width) {
var maxi = 0
var maxj = 0
var maxk = 0
var maxnum = -Double.MAX_VALUE
for (i in 0 until CLASSNUM) {
val score: Float = result[i * (width * height) + j * width + k]
//Log.d("score","${score}")
if (score > maxnum) {
maxnum = score.toDouble()
maxi = i
maxj = j
maxk = k
/*
if(i > 15){ // 사람 보다 큰거 걸리면 그냥 바로 다음
break
}
*/
}
}
if (maxi == PERSON) {
//Log.d("PERSON","PERSON")
intValues[maxj * width + maxk] = 0xFFFFFFFF.toInt()
} else {
//Log.d("BackGround","BackGround")
intValues[maxj * width + maxk] = 0xFF000000.toInt()
}
}
}
val bmpSegmentation = Bitmap.createScaledBitmap(bitmap!!, width, height, true)
val outputBitmap = bmpSegmentation.copy(bmpSegmentation.config, true)
outputBitmap.setPixels(
intValues,
0,
outputBitmap.width,
0,
0,
outputBitmap.width,
outputBitmap.height
)
val transferredBitmap =
Bitmap.createScaledBitmap(outputBitmap, bitmap!!.getWidth(), bitmap!!.getHeight(), true)
val mat = Mat(transferredBitmap!!.height, transferredBitmap!!.width, CvType.CV_8UC1)
//val mat = Mat()
Utils.bitmapToMat(transferredBitmap, mat)
// 이진화 수행
Imgproc.cvtColor(mat, mat, Imgproc.COLOR_RGB2GRAY) // 컬러 이미지를 그레이스케일로 변환
Imgproc.threshold(mat, mat, 128.0, 255.0, Imgproc.THRESH_BINARY) // 이진화 수행
// 2. 컨투어 추출
val contours = mutableListOf<MatOfPoint>()
val hierarchy = Mat()
Imgproc.findContours(mat, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)
Log.d("contours","${contours}")
val ori_mat = Mat(bitmap!!.height, bitmap!!.width, CvType.CV_8UC1)
//val mat = Mat()
Utils.bitmapToMat(bitmap, ori_mat)
Imgproc.drawContours(ori_mat, contours, -1, Scalar(0.0, 255.0, 0.0), 4)
val result_bitmap = Bitmap.createBitmap(
bitmap!!.width, bitmap!!.height,
Bitmap.Config.ARGB_8888
)
Utils.matToBitmap(ori_mat, result_bitmap)
// 메인 스레드에서 처리
runOnUiThread { // 고차함수로 처리하는 방법
binding.imageView.setScaleType(ImageView.ScaleType.FIT_CENTER)
binding.imageView.setImageBitmap(result_bitmap)
}
}
}
thread1.start()
사실 이 방법도 순탄하기만 했던 것은 아니였다.
pytorch 라이브러리와 화면에 결과를 표시하기 위해 사용하는 torchvision 라이브러리가 자꾸 충돌하고 opencv 경로와 충돌하는 문제도 있었다. 결론적으로는 아래와 같은 버전의 라이브러리를 사용했고 경로 설정 및 기타 옵션들을 gradle에 추가해줬다.
// Pytorch
implementation 'org.pytorch:pytorch_android_lite:1.13.1'
implementation 'org.pytorch:pytorch_android_torchvision_lite:1.13.1'
ndk {
// 필요한 ABI만 지정하고 중복을 방지합니다.
abiFilters 'armeabi-v7a','armeabi', 'arm64-v8a'
//abiFilters 'armeabi-v7a', 'arm64-v8a', 'x86', 'x86_64', 'armeabi'
}
packagingOptions {
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
pickFirst 'lib/arm64-v8a/libc++_shared.so'
pickFirst 'jni/arm64-v8a/libfbjni.so'
}
sourceSets {
main {
//jniLibs.srcDirs = ['libs']
jni {
srcDirs 'src/main/jni', 'src/main/jniLibs/libs'
}
}
}
[추가 참고자료]
https://wooono.tistory.com/267
'Android' 카테고리의 다른 글
[안드로이드/소셜로그인&Oauth] 안드로이드에서 애플(Apple Sign-In) 로그인 구현하기 (1) | 2024.01.29 |
---|---|
[안드로이드/DI] 의존성 주입(DI) & Hilt 시작해보기! (0) | 2024.01.09 |
[안드로이드/아키텍쳐] 안드로이드 공식 권장 아키텍쳐 (구vs신버전) (0) | 2023.07.19 |
[안드로이드/아키텍쳐] AAC ViewModel 사용하기 (0) | 2023.07.13 |
[안드로이드/아키텍쳐] MVC, MVVM 패턴 그리고 ViewModel (0) | 2023.07.13 |