안드로이드 YOLO v8 -2

알로에·2023년 4월 4일
4

android camera image 분석
YOLO v8을 위한 전처리

cameraX를 이용한 카메라 미리 보기는 이전 글을 참고하면 된다.


✔ 1. 화면 설정

  1. 가로모드 설정
    우리는 화면을 가로모드로 받아와서 이미지 분석을 할 것이다. 따라서
    manifest에서 가로모드 설정을 해준다.
android:screenOrientation="landscape"
android:configChanges="keyboardHidden|orientation"

위의 사진은 manifest 파일의 일부이다. 위의 사진과 같이 activity 안에 해당 코드를 추가해주면 된다.

  1. 액션바, 타이틀바 제거
    이 경우 필수는 아니지만, 화면을 보는데 방해되서 없앨 것이다.
    values -> themes -> themes.xml 파일에 아래 코드를 추가한다.
    night 버전에도 추가하면 된다.
<item name="windowNoTitle">true</item>
<item name="windowActionBar">false</item>
<item name="android:windowFullscreen">true</item>


위의 사진과 같이 themes.xml 파일에 코드를 추가하면된다.

  1. 자동꺼짐 해제
    앱을 실행하면 알겠지만, 화면을 터치하지 않고 아무런 움직임이 없다면 특정 시간 후에 화면이 자동으로 꺼진다. 마찬가지로 필수는 아니지만 해당 앱이 도중에 꺼지질 않길 바란다면 아래 코드를 메인 액티비티에서 추가하면된다.
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)

✔ 2. ImageAnalysis 객체 생성

//분석 중이면 그 다음 화면이 대기중인 것이 아니라 계속 받아오는 화면으로 새로고침 함. 분석이 끝나면 그 최신 사진을 다시 분석
 val analysis = ImageAnalysis.Builder().setTargetAspectRatio(AspectRatio.RATIO_16_9)
            .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST).build()

이후 analysis객체에 쓰레드를 할당해주고 이미지 처리하는 메서드를 추가해준다.

//여기서 it == imageProxy 객체이다. 
 analysis.setAnalyzer(Executors.newSingleThreadExecutor()) {
            imageProcess(it)
            it.close()
        }
        
 private fun imageProcess(imageProxy: ImageProxy) {
		//이곳에서 이미지를 처리하는 코드를 추가하면 된다. 
    }

✔ 3. ImageProxy --> Bitmap 변환 메서드

아래 사진과 같이 새로운 클래스를 할당한다. (가독성을 위해)

이후 아래와 같이 코드를 추가한다.

class DataProcess {
    companion object {
        const val INPUT_SIZE = 640
    }

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

imageProxy에서 bitmap을 만들고, 640x640 의 bitmap으로 변환한다.
이미지를 변환하는 이유는 YOLO v8 모델을 학습시킬때 640x640의 이미지로 학습했기 때문에, 추론을 할 때에도 같은 640x640의 이미지 이여야 한다.

✔ 4. Bitmap --> FloatBuffer 변환 메서드

YOLO 모델은 .pt 파일로 파이토치 파일이다. 자바(or 코틀린)에는 ultralytics 라이브러리가 없다. 따라서 .onnx 형식으로 변환 한 뒤에 OnnxRuntime 라이브러리를 이용해서 추론을 한다.
OnnxRuntime은 입력으로 이미지를 buffer로 담아야 하기 때문에 이미지를 FloatBuffer에 담는 코드를 추가한다.

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

        val area = INPUT_SIZE * INPUT_SIZE
        val bitmapData = IntArray(area) //한 사진에서 대한 정보, 640x640 사이즈
        bitmap.getPixels(
            bitmapData,
            0,
            bitmap.width,
            0,
            0,
            bitmap.width,
            bitmap.height
        ) // 배열에 정보 담기

        //배열에서 하나씩 가져와서 buffer 에 담기
        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() // position 0
        return buffer
    }

주석에 설명한 것과 같이 bitmap에서 픽셀데이터를 배열에 넣고, 이후 buffer에 담는다.

해당 함수를 메인 액티비티의 imageProcess 메서드의 내용에 추가하면 된다.

private fun imageProcess(imageProxy: ImageProxy) {
        val bitmap = dataProcess.imageToBitmap(imageProxy)
        val floatBuilder = dataProcess.bitmapToFloatBuffer(bitmap)
}

여기까지가 추론을 위한 사진의 전처리 단계이다.
이후 해당 버퍼를 추론의 입력으로 넣고, 추론된 배열에 NMS처리와 화면에 Rect 객체를 그리면 YOLO v8 Object Detection 코드의 완성이다.

전체 코드는 아래와 같다.

// MainActivity 클래스
import android.content.pm.PackageManager
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import android.widget.Toast
import androidx.camera.core.AspectRatio
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.app.ActivityCompat
import java.util.concurrent.Executors

const val PERMISSION = 1

class MainActivity : AppCompatActivity() {
    private lateinit var previewView: PreviewView

    private val dataProcess = DataProcess()

    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()

        //카메라 켜기
        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()

        // 16:9 화면으로 받아옴
        val preview = Preview.Builder().setTargetAspectRatio(AspectRatio.RATIO_16_9).build()

        // preview 에서 받아와서 previewView 에 보여준다.
        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 floatBuilder = dataProcess.bitmapToFloatBuffer(bitmap)
    }

    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.graphics.Bitmap
import androidx.camera.core.ImageProxy
import java.nio.FloatBuffer

class DataProcess {
    companion object {
        const val BATCH_SIZE = 1
        const val INPUT_SIZE = 640
        const val PIXEL_SIZE = 3
    }

    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 = 255.0f
        val buffer = FloatBuffer.allocate(BATCH_SIZE * PIXEL_SIZE * INPUT_SIZE * INPUT_SIZE)
        buffer.rewind()

        val area = INPUT_SIZE * INPUT_SIZE
        val bitmapData = IntArray(area) //한 사진에서 대한 정보, 640x640 사이즈
        bitmap.getPixels(
            bitmapData,
            0,
            bitmap.width,
            0,
            0,
            bitmap.width,
            bitmap.height
        ) // 배열에 정보 담기

        //배열에서 하나씩 가져와서 buffer 에 담기
        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() // position 0
        return buffer
    }
}

0개의 댓글