YOLOv8 pose estimation 안드로이드 -1

알로에·2023년 4월 8일
5

YOLOv8-Pose Estimation

목록 보기
1/2
post-thumbnail

YOLOv8 pose estimation

YOLOv8 pose estimation을 안드로이드에 적용해보자.

pose estimation이 뭘까?

위 사진은 https://paperswithcode.com/task/pose-estimation 에서 따온 사진으로 Object Detection은 사람을 검출하면 사각형으로 사람의 테두리를 검출하지만, Pose Estimation은 사람의 각 관절을 검출하고 관절을 점으로, 관절 끼리를 선으로 그리는 것이다.

✔ 1. yolov8n-pose.pt -> yolov8-pose.onnx

https://docs.ultralytics.com/tasks/pose/
위의 사이트는 yolo v8을 만든 ultralytics 공식 사이트이다.
내리다 보면 아래 사진이 보이게 된다 .

어떤 모델을 다운받아도 관계없지만, 이번에는 nano 모델로 다운 받았다.

다운받은 yolov8n-pose.pt가 있는 곳에 .py 파일을 하나 만든다.

from ultralytics import YOLO

model = YOLO(model='yolov8n-pose.pt')
model.export(format="onnx")

안드로이드에는 .pt 파일을 추론을 도울 라이브러리가 따로 없다. 따라서 .onnx 파일로 변환한 후에, OnnxRuntime 라이브러리를 통해 실시간 포즈 검출을 할 예정이다.

✔ 2. 전처리

전처리 단계의 경우 이전 글이였던 YOLOv8 안드로이드 글에서
1편 2편 3편과 동일한 코드이며, 자세하게 설명되어있다. 자세히 보려면
이전 글을 참고하길 바란다.

  1. 앱을 하나 만들고, 앱 수준의 그래들에 아래의 라이브러리를 종속성에 추가한다.
	 // https://mvnrepository.com/artifact/com.microsoft.onnxruntime/onnxruntime-android
    implementation 'com.microsoft.onnxruntime:onnxruntime-android:1.12.1'

    // CameraX core library using the camera2 implementation
    def camerax_version = "1.3.0-alpha05"
    // The following line is optional, as the core library is included indirectly by camera-camera2
    implementation "androidx.camera:camera-core:${camerax_version}"
    implementation "androidx.camera:camera-camera2:${camerax_version}"
    // If you want to additionally use the CameraX Lifecycle library
    implementation("androidx.camera:camera-lifecycle:${camerax_version}")
    // If you want to additionally use the CameraX View class
    implementation("androidx.camera:camera-view:${camerax_version}")

맨 위의 라이브러리는 onnxruntime 라이브러리 이며 그 외에는 cameraX라고 camera를 사용하기 위한 라이브러리이다.

  1. 좌측 상단의 Android를 눌러 Project로 변경한다. app -> src -> main, 메인 폴더를 우클릭 하고 new -> Directory를 선택하고 assets를 생성한다. 1번에서 변환했던 yolov8n-pose.onnx를 assets 안에 저장한다.

  2. 권한, 화면 가로 고정, 타이틀바 액션바 제거
    manifests.xml 파일에 카메라 권한을 추가한다.

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

액티비티 태그 안에서 가로모드로 고정한다.

android:configChanges="keyboardHidden|orientation"
android:screenOrientation="landscape"

아래 사진은 manifest.xml 파일의 일부이다.

우측 폴더에서 res -> values -> themes 에서 액션 바와 타이틀 바를 제거한다.

<!-- Customize your theme here. -->
<item name="windowNoTitle">true</item>
<item name="windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>

해도 안해도 상관은 없지만, 한다면 night 버전도 같이 하면 된다.

  1. 사용자에게 카메라 권한 요구

메인 액티비티에서 permission 값 부여

 companion object {
        const val PERMISSION = 1
    }

permission 권한 허용 요청 코드

 private fun setPermissions() {
        val permissions = ArrayList<String>()
        permissions.add(android.Manifest.permission.CAMERA)

        permissions.forEach {
            if (ActivityCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED) {
                ActivityCompat.requestPermissions(this, permissions.toTypedArray(), PERMISSION)
            }
        }
    }

권한이 하나여서 list 형태일 필요는 없지만, 나중에 다른 프로젝트와 합칠 경우 추가되는 권한을 대비해서 list 형태로 선언했다.

권한 요청에 대한 처리 코드

override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        if (requestCode == PERMISSION) {
            grantResults.forEach {
                if (it != PackageManager.PERMISSION_GRANTED) {
                    Toast.makeText(this, "권한을 허용하지 않으면 사용할 수 없습니다!", Toast.LENGTH_SHORT).show()
                    finish()
                }
            }
        }
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    }

사용자가 카메라 권한 요청에 거절을 눌렀을 경우 앱을 종료시킨다.

  1. 화면에 보여줄 previewView 객체 선언
    카메라에서 받아온 화면을 다시 핸드폰의 화면에 보여주기 위한 코드이다.
    activity_main.xml파일에 아래 코드를 추가한다.
 <androidx.camera.view.PreviewView
        android:id="@+id/previewView"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

아래 사진과 같이 추가하면 된다.

이후 메인 액티비티에서 해당 view를 불러온다.

private lateinit var previewView: PreviewView

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        previewView = findViewById(R.id.previewView)
}
  1. 카메라 데이터 -> View에 보여주기, image analysis 객체 생성
private fun setCamera() {
        val processCameraProvider =
            ProcessCameraProvider.getInstance(this).get()                        // 카메라 제공 객체

        previewView.scaleType =
            PreviewView.ScaleType.FILL_CENTER                                           // 전체화면

        val cameraSelector =
            CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK) // 후면 카메라
                .build()

        val preview =
            Preview.Builder().setTargetAspectRatio(AspectRatio.RATIO_16_9).build()      // 16:9 화면

        preview.setSurfaceProvider(previewView.surfaceProvider)

        val analysis = ImageAnalysis.Builder().setTargetAspectRatio(AspectRatio.RATIO_16_9)
            .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
            .build()   // 최신 화면으로 분석, 비율도 동일

        analysis.setAnalyzer(Executors.newSingleThreadExecutor()) {
            imageProcess(it)
            it.close()
        }
        // 생명주기 메인 액티비티에 귀속
        processCameraProvider.bindToLifecycle(this, cameraSelector, preview, analysis)

}

주석 처리로 간단하게 설명을 해놓은 부분을 참고하면 좋을 것 같다.
카메라로 받아온 화면을 16:9 비율의 전체 화면으로 보여줄 예정이며,
이미지 분석을 위한 새로운 쓰레드를 생성한다. (1개만) 생명주기를 메인 액티비티와 동일하게 하여, 앱이 종료되면 카메라, 카메라 분석 등등 작업을 종료한다.

imageProcess 메서드는 아래 정의되어있다.

private fun imageProcess(imageProxy: ImageProxy) {

//이미지 처리 

}

이 코드는 이미지를 분석하는 코드이다. 이 메소드 내부에는
카메라 -> image analysis객체에서 받아온 화면을 적절히 변환해서 pose Model 에 추론하고 결과값을 화면에 보여주는 코드가 추가될 예정이다. 그러기 위해서는
-> 모델을 불러와야 하며
-> 사진의 전처리 코드가 추가되어야 하며
-> 추론된 결과값에 대해 후처리를 통해 화면 속 관절들에 대한 적절한 처리를 해주면 된다.

  1. 모델 불러오기
    assets 안에 있는 onnx 파일을 불러와야 한다.
    assets 안에 있는 파일들은 컴파일 시 압축되기 때문에 경로를 통해 접근할 수 없다. 먼저 불러오고, 접근하면 된다.

따라서 모델을 불러오고, 사진에 대한 전처리 등을 처리할 클래스를 하나 생성한다.

class DataProcess(val context: Context) {
}

아래 메서드들을 DataProcess의 내부 메서드에 추가하면 된다.

companion object {
        const val BATCH_SIZE = 1
        const val INPUT_SIZE = 640
        const val PIXEL_SIZE = 3
        const val FILE_NAME = "yolov8n-pose.onnx"
}
    
fun loadPoseModel() {
        //onnx 파일 불러오기
        val assetManager = context.assets
        val outputFile = File(context.filesDir.toString() + "/" + FILE_NAME)

        assetManager.open(FILE_NAME).use { inputStream ->
            FileOutputStream(outputFile).use { outputStream ->
                val buffer = ByteArray(4 * 1024)
                var read: Int
                while (inputStream.read(buffer).also { read = it } != -1) {
                    outputStream.write(buffer, 0, read)
                }
            }
        }
    }

모델을 불러오는 코드이다. BATCH_SIZE, PIXEL_SIZE, INPUT_SIZE에 대한 내용은 아래 모델에 대해 설명할 때 설명하겠다. 우선 무시하고 넘어가도 무방하다.

  1. 모델의 입력을 위한 사진의 전처리 코드 정의
    지금까지 간단히 정리하자면, 사진을 받아오고 yolov8 pose 모델을 불러왔다. 이제 모델의 입력을 보고 입력에 맞게 사진을 수정할 예정이다.
    https://netron.app/ 이곳에서 모델의 구조를 확인할 수 있다.


모델의 앞 부분이다. 입력이 [1 3 640 640] 이다.
순서대로 배치 사이즈, 픽셀 차원(RGB), 가로 픽셀, 세로 픽셀 이다.
사진 한 장에 대해 추론이니, 배치 사이즈는 1이다. 사진에서 ARGB의 4차원이 아닌 3차원 RGB값 에 대해 추론하므로 3이며, 사진의 가로 세로 크기가 640이여야 한다는 소리이다.

다시 안드로이드 코드로 돌아가서 이미지에 대해 [640 640] RGB 형태의 이미지로 변환하는 코드를 작성하면 된다. (DataProcess 클래스 내부)

fun imageToBitmap(imageProxy: ImageProxy): Bitmap {
        val bitmap = imageProxy.toBitmap()
        return Bitmap.createScaledBitmap(bitmap, INPUT_SIZE, INPUT_SIZE, true)
}

imageAnalysis에서 받아온 image 객체에 대해서 bitmap으로 변환하는 코드이다. 이때 입력 사진이 16:9 비율의 사진이지만, [640x640]으로 사진의 크기를 조절해서 반환한다.
아래 코드는 bitamp -> buffer에 담는 코드이다.

fun bitmapToFloatBuffer(bitmap: Bitmap): FloatBuffer {
        val imageSTD = 255f
        val buffer = FloatBuffer.allocate(BATCH_SIZE * PIXEL_SIZE * INPUT_SIZE * INPUT_SIZE)
        buffer.rewind()

        val area = INPUT_SIZE * INPUT_SIZE
        val bitmapData = IntArray(area)
        bitmap.getPixels(
            bitmapData,
            0,
            bitmap.width,
            0,
            0,
            bitmap.width,
            bitmap.height
        ) //배열에 RGB 담기

        //하나씩 받아서 버퍼에 할당
        for (i in 0 until INPUT_SIZE - 1) {
            for (j in 0 until INPUT_SIZE - 1) {
                val idx = INPUT_SIZE * i + j
                val pixelValue = bitmapData[idx]
                // 위에서 부터 차례대로 R 값 추출, G 값 추출, B값 추출 -> 255로 나누어서 0~1 사이로 정규화
                buffer.put(idx, ((pixelValue shr 16 and 0xff) / imageSTD))
                buffer.put(idx + area, ((pixelValue shr 8 and 0xff) / imageSTD))
                buffer.put(idx + area * 2, ((pixelValue and 0xff) / imageSTD))
                //원리 bitmap == ARGB 형태의 32bit, R값의 시작은 16bit (16 ~ 23bit 가 R영역), 따라서 16bit 를 쉬프트
                //그럼 A값이 사라진 RGB 값인 24bit 가 남는다. 이후 255와 AND 연산을 통해 맨 뒤 8bit 인 R값만 가져오고, 255로 나누어 정규화를 한다.
                //다시 8bit 를 쉬프트 하여 R값을 제거한 G,B 값만 남은 곳에 다시 AND 연산, 255 정규화, 다시 반복해서 RGB 값을 buffer 에 담는다.
            }
        }
        buffer.rewind()
        return buffer
}

onnx 추론을 위해서 아래 메서드를 보면 이해가 쉬울듯 하다.

입력으로 640x640 bitmap 을 입력으로 넣는게 아니라, Tensor 즉 3차원 배열을 넣어야 한다. 그럼 이 OnnxTensor는 어떻게 만드냐면,


FloatBuffer와 그 형태 shape를 통해 만들 수 있다.

ortEnviroment는 onnxruntime의 실행 객체이다.

그래서 위의 bitmap을 FloatBuffer에 담는 메서드를 추가한 것이다.

지금까지는 DataProcess의 내부에 위의 메서드를 정의했다. 이제 메인 액티비티에서 정의된 메서드를 불러오는 코드를 추가하면 된다.

  1. 사진의 전처리

메인 액티비티에서 아래 코드를 추가하면 된다.

private lateinit var ortEnvironment: OrtEnvironment
private lateinit var session: OrtSession

private val dataProcess = DataProcess(context = this)

private fun load() {
        dataProcess.loadPoseModel()

        // 추론을 위한 객체 생성
        ortEnvironment = OrtEnvironment.getEnvironment()
        session =
            ortEnvironment.createSession(
                this.filesDir.absolutePath.toString() + "/" + DataProcess.FILE_NAME,
                OrtSession.SessionOptions()
            )
}

load 메서드의 내부에는

  1. assets안에 있는 YOLOv8n pose model 불러오기
  2. onnxruntime의 실행 객체는 ortEnviroment 선언
  3. onnx 모델의 객체가 될 session 선언 순이다.

그리고 아까 선언했던, imageProcess 메서드 내부에 아래 코드를 추가한다.

 private fun imageProcess(imageProxy: ImageProxy) {

        val bitmap = dataProcess.imageToBitmap(imageProxy)
        val buffer = dataProcess.bitmapToFloatBuffer(bitmap)
        val inputName = session.inputNames.iterator().next()
        //모델의 요구 입력값 [1 3 640 640] [배치 사이즈, 픽셀(RGB), 너비, 높이], 모델마다 크기는 다를 수 있음.
        val shape = longArrayOf(
            DataProcess.BATCH_SIZE.toLong(),
            DataProcess.PIXEL_SIZE.toLong(),
            DataProcess.INPUT_SIZE.toLong(),
            DataProcess.INPUT_SIZE.toLong()
        )
        val inputTensor = OnnxTensor.createTensor(ortEnvironment, buffer, shape)
        val resultTensor = session.run(Collections.singletonMap(inputName, inputTensor))
        val outputs = resultTensor[0].value as Array<*>
    }
  1. 이미지 객체 -> 비트맵 변환
  2. 비트맵 -> floatBuffer에 담기
  3. onnx모델의 이름 불러오기
  4. 모델의 요구하는 shape 지정 == [1 3 640 640]
  5. onnxTensor 생성
  6. 추론을 통해 결과텐서 생성, 결과배열(outputs) 반환 순서이다.

그리고 이 선언된 메서드들을 메인 액티비티에 추가하면된다.

아래는 전체 코드이다.

//메인 액티비티 코드 

import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import android.content.pm.PackageManager
import android.os.Bundle
import android.view.WindowManager
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.app.ActivityCompat
import java.util.*
import java.util.concurrent.Executors

class MainActivity : AppCompatActivity() {
    private lateinit var previewView: PreviewView
    private lateinit var ortEnvironment: OrtEnvironment
    private lateinit var session: OrtSession

    private val dataProcess = DataProcess(context = this)

    companion object {
        const val PERMISSION = 1
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        previewView = findViewById(R.id.previewView)

        //자동꺼짐 해제
        window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

        //권한 허용
        setPermissions()

        //모델 불러오기
        load()

        //카메라 켜기
        setCamera()
    }

    private fun setCamera() {
        val processCameraProvider =
            ProcessCameraProvider.getInstance(this).get()                        // 카메라 제공 객체

        previewView.scaleType =
            PreviewView.ScaleType.FILL_CENTER                                           // 전체화면

        val cameraSelector =
            CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK) // 후면 카메라
                .build()

        val preview =
            Preview.Builder().setTargetAspectRatio(AspectRatio.RATIO_16_9).build()      // 16:9 화면

        preview.setSurfaceProvider(previewView.surfaceProvider)

        val analysis = ImageAnalysis.Builder().setTargetAspectRatio(AspectRatio.RATIO_16_9)
            .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
            .build()   // 최신 화면으로 분석, 비율도 동일

        analysis.setAnalyzer(Executors.newSingleThreadExecutor()) {
            imageProcess(it)
            it.close()
        }
        // 생명주기 메인 액티비티에 귀속
        processCameraProvider.bindToLifecycle(this, cameraSelector, preview, analysis)

    }

    private fun imageProcess(imageProxy: ImageProxy) {

        val bitmap = dataProcess.imageToBitmap(imageProxy)
        val buffer = dataProcess.bitmapToFloatBuffer(bitmap)
        val inputName = session.inputNames.iterator().next()
        //모델의 요구 입력값 [1 3 640 640] [배치 사이즈, 픽셀(RGB), 너비, 높이], 모델마다 크기는 다를 수 있음.
        val shape = longArrayOf(
            DataProcess.BATCH_SIZE.toLong(),
            DataProcess.PIXEL_SIZE.toLong(),
            DataProcess.INPUT_SIZE.toLong(),
            DataProcess.INPUT_SIZE.toLong()
        )
        val inputTensor = OnnxTensor.createTensor(ortEnvironment, buffer, shape)
        val resultTensor = session.run(Collections.singletonMap(inputName, inputTensor))
        val outputs = resultTensor[0].value as Array<*>
    }

    private fun load() {
        dataProcess.loadPoseModel()

        // 추론을 위한 객체 생성
        ortEnvironment = OrtEnvironment.getEnvironment()
        session =
            ortEnvironment.createSession(
                this.filesDir.absolutePath.toString() + "/" + DataProcess.FILE_NAME,
                OrtSession.SessionOptions()
            )
    }

    override fun onRequestPermissionsResult(
        requestCode: Int,
        permissions: Array<out String>,
        grantResults: IntArray
    ) {
        if (requestCode == PERMISSION) {
            grantResults.forEach {
                if (it != PackageManager.PERMISSION_GRANTED) {
                    Toast.makeText(this, "권한을 허용하지 않으면 사용할 수 없습니다!", Toast.LENGTH_SHORT).show()
                    finish()
                }
            }
        }
        super.onRequestPermissionsResult(requestCode, permissions, grantResults)
    }

    private fun setPermissions() {
        val permissions = ArrayList<String>()
        permissions.add(android.Manifest.permission.CAMERA)

        permissions.forEach {
            if (ActivityCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED) {
                ActivityCompat.requestPermissions(this, permissions.toTypedArray(), PERMISSION)
            }
        }
    }
}
//DataProcess 클래스 

import android.content.Context
import android.graphics.Bitmap
import android.graphics.RectF
import androidx.camera.core.ImageProxy
import java.io.File
import java.io.FileOutputStream
import java.nio.FloatBuffer
import java.util.*
import kotlin.collections.ArrayList
import kotlin.math.max
import kotlin.math.min

class DataProcess(val context: Context) {

    companion object {
        const val BATCH_SIZE = 1
        const val INPUT_SIZE = 640
        const val PIXEL_SIZE = 3
        const val FILE_NAME = "yolov8n-pose.onnx"
    }

    fun imageToBitmap(imageProxy: ImageProxy): Bitmap {
        val bitmap = imageProxy.toBitmap()
        return Bitmap.createScaledBitmap(bitmap, INPUT_SIZE, INPUT_SIZE, true)
    }

    fun bitmapToFloatBuffer(bitmap: Bitmap): FloatBuffer {
        val imageSTD = 255f
        val buffer = FloatBuffer.allocate(BATCH_SIZE * PIXEL_SIZE * INPUT_SIZE * INPUT_SIZE)
        buffer.rewind()

        val area = INPUT_SIZE * INPUT_SIZE
        val bitmapData = IntArray(area)
        bitmap.getPixels(
            bitmapData,
            0,
            bitmap.width,
            0,
            0,
            bitmap.width,
            bitmap.height
        ) //배열에 RGB 담기

        //하나씩 받아서 버퍼에 할당
        for (i in 0 until INPUT_SIZE - 1) {
            for (j in 0 until INPUT_SIZE - 1) {
                val idx = INPUT_SIZE * i + j
                val pixelValue = bitmapData[idx]
                // 위에서 부터 차례대로 R 값 추출, G 값 추출, B값 추출 -> 255로 나누어서 0~1 사이로 정규화
                buffer.put(idx, ((pixelValue shr 16 and 0xff) / imageSTD))
                buffer.put(idx + area, ((pixelValue shr 8 and 0xff) / imageSTD))
                buffer.put(idx + area * 2, ((pixelValue and 0xff) / imageSTD))
                //원리 bitmap == ARGB 형태의 32bit, R값의 시작은 16bit (16 ~ 23bit 가 R영역), 따라서 16bit 를 쉬프트
                //그럼 A값이 사라진 RGB 값인 24bit 가 남는다. 이후 255와 AND 연산을 통해 맨 뒤 8bit 인 R값만 가져오고, 255로 나누어 정규화를 한다.
                //다시 8bit 를 쉬프트 하여 R값을 제거한 G,B 값만 남은 곳에 다시 AND 연산, 255 정규화, 다시 반복해서 RGB 값을 buffer 에 담는다.
            }
        }
        buffer.rewind()
        return buffer
    }

    fun loadPoseModel() {
        //onnx 파일 불러오기
        val assetManager = context.assets
        val outputFile = File(context.filesDir.toString() + "/" + FILE_NAME)

        assetManager.open(FILE_NAME).use { inputStream ->
            FileOutputStream(outputFile).use { outputStream ->
                val buffer = ByteArray(4 * 1024)
                var read: Int
                while (inputStream.read(buffer).also { read = it } != -1) {
                    outputStream.write(buffer, 0, read)
                }
            }
        }
    }
}

지금까지 yolov8n-pose 모델을 안드로이드에 적용하고, 사진을 알맞게 처리해서 모델의 출력을 반환했다. 다음 글에서는 모델의 출력에 대해 적절히 처리해서 화면에 보여주는 것까지 작성할 예정이다.

2개의 댓글

comment-user-thumbnail
2024년 1월 5일

안녕하십니까
글을보고 문의 드리고자 합니다.
제가 안드로이드 보드로 자율주행을 하려고 하는데
yolo8로 지도제작이 가능하신지요?
가능하시다면 어떻게 코웍을 해야 하는지 부탁 드립니다.
댓글이나 답변 부탁 드립니다.

감사합니다.

010 8857 9788

1개의 답글