Android 앱 이미지 Downsampling 및 업로드

yo_hou·2024년 6월 28일
0
post-thumbnail

서론

왜 다운샘플링을 하였는가?

이미지를 multipart/form-data로 업로드하는 기능 구현하는 과정이었다.

요즘 스마트폰이 1억화소(약 40MB)까지 지원되기에, 고용량 이미지 업로드를 테스트해보는 게 좋다고 생각이 들어 테스트를 진행해보았다.

역시나 이슈가 발생했는데... 문제가 한두개가 아니었다.
이슈는 아래와 같았다.

  • 대용량 이미지 처리로 인한 메모리 이슈
  • 서버에서 최대 8MB의 이미지만 받도록 되어있어 업로드 불가
  • 업로드 시간이 오래걸림
  • 고용량 서버 이미지를 로드하는데에 오랜 시간이 걸림

원본 이미지를 반드시 보존해야만하는 기능이라면(채팅 이미지 상세) 이미지 화질 그대로 보존하는게 맞을 것이다.
하지만 내가 구현하는 기능에서 사용되는 이미지는 프로필 이미지와 같은 작은 이미지였기 때문에, 화질이 조금 떨어져도 티가 나지 않는다.

즉, 128*128 정도의 이미지뷰에 2048*2048의 이미지를 넣는 것은 사용자가 인지하지 못할뿐더러 리소스가 낭비되는 작업이기에 사이즈를 줄이는 작업이 필요한 것이다.

이미지의 화질을, 즉 사이즈를 줄이는 과정을 Downsampling이라고 한다.

Downsampling

그렇다면 Android에서는 다운샘플링을 어떻게 진행하는가?

많은 방법이 있겠지만, 나는 흔히 사용되는 Bitmap 축소 방식을 적용하고자 한다.

진행 순서는 아래와 같다.

  1. 이미지를 Byte단위 형태로 가져온다.
  2. Byte단위로 가져온 이미지의 크기를 추출한다.
  3. 기존 이미지 사이즈에서 얼만큼 축소할 것인지에 대한 비율을 도출한다.
  4. 비율을 적용하여 다운샘플링된 비트맵을 반환한다.

순서로 쭉 나타내보면 크게 어렵진 않지만, 나는 이미지 사이즈를 가져오는 부분과 축소 공식을 적용하는 부분에서 거하게 삽질하였다.

내가 비트맵을 많이 다뤄보지도 않았을 뿐더러, 어떤 이미지 사이즈던 "적당한"이미지 사이즈로 바꾼다는 것을 공식화 하는게 어렵게 느껴졌기 때문이다.

물론 Android Developer의 예제나 Stack Overflow에 있는 정보들을 통해 이를 해결할 수 있었다.

이제 위의 진행 순서대로 코드를 뜯어보도록 하겠다.

1. 이미지 가져오기

uri를 통해 이미지를 Byte단위인 BufferedInputStream 형태로 가져왔다.

val input = BufferedInputStream(context.contentResolver.openInputStream(uri))
input.mark(input.available())

2. 이미지 크기 추출

BitmapFactory의 Option을 활용하여 이미지 사이즈를 추출하고, 이에 따른 축소 비율을 도출하는 코드이다.

inJustDecodeBounds는 디코딩 옵션 속성 중 하나로, 디코딩 간 메모리 할당 여부에 대한 속성이다.
inJustDecodeBounds를 true로 설정한 이후 decoding하게 되면, decoding되는 리소스들이 메모리에 할당하지 않은채로(bitmap 반환값은 null) 비트맵의 너비와 높이를 알 수 있다.
만약 크기가 큰이미지를 그대로 decoding한다면 메모리 이슈로 인해 앱이 죽어버리기 때문에, 대용량 이미지에 대비하여 반드시 거쳐야 하는 과정이라고 볼 수 있다.
이 과정을 제대로 이해하지 못해 삽질한 부분도 있다...

inJustDecodeBounds를 true로 만든 후에 decoding을 거치면서 options에 비트맵에 대한 너비와 높이가 구해진다.

calculateInSampleSize는 options에 구해진 너비와 높이를 기준으로 얼만큼 축소할 것인지 반환하는 함수이다.
반환 결과로 inSampleSize가 4가 나왔다면 가로와 세로를 1/4씩 줄이겠다는 의미이기에, 총 이미지 사이즈는 1/16이 줄어들게 되는 것이다.

return BitmapFactory.Options().run {
        inJustDecodeBounds = true
        BitmapFactory.decodeStream(input, null, this)

        inSampleSize = calculateInSampleSize(this)

        inJustDecodeBounds = false
        input.close()
}

3. 이미지 축소 비율 도출

그렇다면 축소 비율을 어떻게 선정하였는가?
이미지 크기가 작으면서도 화질이 어느정도 보장되는 너비와 높이에 대해 찾아보았고 768*768 정도의 크기가 어느 정도 안정적인 화질을 보임을 알게 되면서 기준점을 768로 잡았다.
물론 이것보다 더 훌륭한 로직을 짤 수도 있으며, 기능과 용도에 따라 로직이 달라질 수도 있다.

아래 함수에서 MAX_WIDTH와 MAX_HEIGHT의 값이 768이며, 너비와 높이가 768보다 작을때까지 2씩 나누어주면서 inSampleSize를 2배씩 증가시키는 로직이다.

나는 여기서 inSampleSize가 왜 2의 지수형태인지 궁금하였는데, decoder가 어차피 2의 거듭제곱에 가장 가까운 값으로 내림하여 최종 값을 사용하기 때문에, 내림의 과정 없이 최적의 효율을 내기 위해서는 2의 배수가 좋다고 한다.

private fun calculateInSampleSize(options: BitmapFactory.Options): Int {
	val (height: Int, width: Int) = options.run { outHeight to outWidth }
    var inSampleSize = 1

	if (height > MAX_HEIGHT || width > MAX_WIDTH) {

		val halfHeight: Int = height / 2
        val halfWidth: Int = width / 2

		while (halfHeight / inSampleSize >= MAX_HEIGHT && halfWidth / inSampleSize >= MAX_WIDTH) {
        	inSampleSize *= 2
        }
    }

	return inSampleSize
}

4. 비트맵 반환

위에서 도출된 inSampleSize가 반영된 options를 가지고 다시 decoding해준 뒤, 비트맵을 회전시켜주는 과정이다.
스마트폰을 회전하여 촬영하는 경우에는, 촬영된 이미지가 회전된채 나오지 않기 때문에, 이를 기존에 의도했던 대로 다시 회전시킨다.

이번에는 decoding된 bitmap을 반환해야 하기에 inJustDecodeBounds를 false로 놓은 것을 볼 수 있다.

options를 반영하여 decoding하게 되면 최종적으로 Downsampling 된 비트맵을 가져올 수 있게 된다.

혹시 몰라 회전시키는 함수도 같이 올려놓는다.

fun decodeBitmapFromUri(context: Context, uri: Uri): Bitmap? {
	val input = BufferedInputStream(context.contentResolver.openInputStream(uri))
    input.mark(input.available())

    return BitmapFactory.Options().run {
        inJustDecodeBounds = true
        BitmapFactory.decodeStream(input, null, this)

        inSampleSize = calculateInSampleSize(this)

        inJustDecodeBounds = false
        input.close()

        val bitmap = BitmapFactory.decodeStream(input, null, this)
        var inputStream = context.contentResolver.openInputStream(uri)
        if(inputStream != null){
            val exif = ExifInterface(inputStream)
            var orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
            if(bitmap != null){
                rotateBitmap(bitmap, orientation)
            }else{
                null
            }
        }else{
            null
        }
    }
}
fun rotateBitmap(bitmap: Bitmap, orientation: Int): Bitmap? {
	val matrix = Matrix()
    when (orientation) {
    	ExifInterface.ORIENTATION_NORMAL -> return bitmap
        ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.setScale(-1f, 1f)
        ExifInterface.ORIENTATION_ROTATE_180 -> matrix.setRotate(180f)
        ExifInterface.ORIENTATION_FLIP_VERTICAL -> {
        	matrix.setRotate(180f)
            matrix.postScale(-1f, 1f)
        }
        ExifInterface.ORIENTATION_TRANSPOSE -> {
        	matrix.setRotate(90f)
            matrix.postScale(-1f, 1f)
        }
        ExifInterface.ORIENTATION_ROTATE_90 -> matrix.setRotate(90f)
        ExifInterface.ORIENTATION_TRANSVERSE -> {
        	matrix.setRotate(-90f)
            matrix.postScale(-1f, 1f)
        }
        ExifInterface.ORIENTATION_ROTATE_270 -> matrix.setRotate(-90f)
        else -> return bitmap
    }
    return try {
    	val bmRotated = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
        bitmap.recycle()
        bmRotated
    } catch (e: OutOfMemoryError) {
    	e.printStackTrace()
        	null
    }
}

마치며...

이렇게 이미지 Downsampling에 대해 알아보았다.
이미지 처리 간 이렇게 다양한 과정이 있는지 알게되어 좀 기쁘달까...ㅎ

잘못된 정보가 있거나 추가되었으면 하는 부분에 대해서 피드백 주시면 적극 반영하도록 하겠습니다!

0개의 댓글