이미지 리사이징 및 비트맵 압축

조관희·2024년 6월 28일
0

들어가기 앞서

“모티부”라는 앱 서비스에서 운동을 완료한 뒤, 사진을 저장하여 유저에게 보여줍니다. 하지만 개발측에서는 이미지를 서버에 바로 저장하지 않고 AWS S3를 이용하여 우회하여 이미지를 클라우드 서버 DB에 직접적으로 저장합니다. 이는 서버의 트래픽을 줄입니다. S3 라는 데이터 저장소에도 용량이 존재합니다. 큰 이미지를 가져다가 넣으면 그 만큼 더 많은 이미지를 얻어올 수 없고 추가적으로 서버에서 이미지를 가져와서 사용할 때도 효율적이 못한 방법입니다. 클라단에서도 이미지를 봐야하는데, 용량이 큰 이미지를 보니 그만큼 이미지 로드되는 시간도 증가합니다. 이는 유저에게 좋지 않은 경험을 주는 요소 중 하나입니다.
모티부 깃허브 코드 확인해보기~

그래서 사진을 리사이징하여서 S3에 저장하였습니다. 안드로이드 시선에서 글을 작성한 것이니 참고해주세요!

  • 개선된 점
    • 3.3MB → 77.5KB 로 용량 감소
      • 약 42배 절감
    • 이미지 로드 속도 감소
      • 2s → 0.2~3s
      • 압축 포맷인 jpg 이미지를 로드할 때, 용량이 크면 느려지니 용량을 줄임으로써, 속도 개선

Bitmap Resize

Loading Large Bitmaps Efficiently in Android

Custom convert Uri to Bitmap

이미지에 대한 Uri가 존재한다고 가정합시다. 해당 Uri를 Bitmap으로 전환할 때, 옵션을 주어서 Bitmap의 크기를 커스텀할 수 있습니다.

우선 BitmapFactory 의 Options API를 사용하여 비트맵 옵션 객체를 가져옵니다.

val options = BitmapFactory.Options()

그리고 옵션에 대해서 몇몇 속성을 설정합니다.

options.apply {
	inJustDecodeBounds = false
	inSampleSize = 3
}
  • inJustDeocdeBoundes
    • 비트맵을 메모리에 할당할 지를 판단합니다.
      - true : 비트맵이 메모리에 할당됩니다.
      - false : 비트맵이 메모리에 할당되지 않습니다.

      만약에 inJustDeocdeBoundes 값이 false 이면 이미지는 보이지 않습니다. 반대로 true 시에는 잘 보입니다.

      val stream = contentResolver.openInputStream(photoUri)
      val bitmap = BitmapFactory.decodeStream(stream, null, options)
      
      binding.imageView.setImageBitmap(bitmap)
  • inSampleSize
    • 설정된 값으로 비율을 적용하여 이미지를 반홥합니다.
      - inSampleSize = 4 라고 가정합니다.
      - 원본 크기 너비/높이 = 1/4
      - 픽셀 수 = 1/16
      - 위와 같이 1000x1000 이미지가 250x250 인 축소된 이미지를 반환합니다.

      간단하게 예시를 살펴보면, BitmapFactory.Options 멤버로 확인할 수 있습니다. 
      
      ```kotlin
      // if, inSampleSize = 1
      options.outHeight
      options.outWidth
      
      >>>
      1000
      1000
      
      // if, inSampleSize = 4
      options.outHeight
      options.outWidth
      
      >>>
      250
      250
      ```

      inSampleSize를 사용할 때, 원래 사진의 바이트를 확인해서 작으면 inSampleSize 에 작은 값을 넣고, 사진의 바이트가 크다면 inSampeSize 에 큰 값을 넣어서 분기처리한다면 더 깔끔한 코드가 될 것 같다.

이 비트맵을 사용하여 이미지를 압축하려고 합니다. 이미지를 압축하고 이미지 사이즈도 확인해보려고 합니다. 이럴 때는 어떻게 해야할까요?

아래의 코드를 통해 확인할 수 있습니다.

val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
val byteArray = outputStream.toByteArray()

// size
byteArray.size

Use ImageDecoder

이미지에 대한 Uri를 간단하게 ImageDecoder로 복호화하여 비트맵을 만들 수 있습니다.

val source = ImageDecoder.createSource(contentResolver, photoUri)

*주의사항은 위 Android API 28이상에서 호환되는 방식이므로 하위 호환성을 위해서는 아래의 코드를 참고해주세요

fun Context.createUriToBitmap(photoUri: Uri): Bitmap =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        val source =
            ImageDecoder.createSource(contentResolver, photoUri)
        ImageDecoder.decodeBitmap(source)
    } else {
        MediaStore.Images.Media.getBitmap(
            contentResolver,
            photoUri
        )
    }

다음으로 이미지의 비트맵을 만들어주면 됩니다.

val bitmap = ImageDecoder.decodeBitmap(source)

해당 비트맵을 원하는 이미지로 압축한 후 이미지의 크기를 가져오기 위해서는 어떻게 해야할까요?

그렇게 하기 위해서는 OutpuStream을 만들어주어야 합니다. 그 이후, 비트맵 압축 시 OutputStream에 데이터를 받아서 ByteArray로 변환 후 크기를 확인할 수 있습니다. 예시 코드는 아래와 같습니다.

val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
val byteArray = outputStream.toByteArray()

// size
byteArray.size

Bitmap Compress

위에서 BitmapFactory 비트맵 추출과 ImageDecoder 비트맵 추출에 대해서 알아보았습니다.

알아보면서 해당 비트맵을 이미지로 압축하는 과정은 동일하게 진행되었고, 해당 과정에 대해서 살펴보겠습니다.

다음과 같이 비트맵을 압축하기 위해서는 Bitmap의 compress 함수를 사용하게 됩니다. 압축을 성공하면 compress는 true를 반환합니다.

bitmap.compress(
	format = Bitmap.CompressFormat.JPEG, 
	quality = 100, 
	stream = outputStream
)
  • format
    • 압축된 이미지의 품질 형식
      • ex) JPEG, PNG, WEBG
  • quality
    • 압축할 이미지의 품질
      • 이미지 형식마다 상이
      • 0~100 값을 가짐
  • stream
    • 압축된 데이터를 쓰기 위한 출력 스트림

ImageDecoder 방법

간단한 예시로, 3.35MB 이미지를 갤러리에서 가져와서 압축해보겠습니다. quality는 100으로 진행한다.

val source =
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        ImageDecoder.createSource(contentResolver, photoUri)
    } else {
        TODO("VERSION.SDK_INT < P")
    }
val newBitmap = ImageDecoder.decodeBitmap(source)

>>> newBitmap
53673984
val outputStream = ByteArrayOutputStream()
newBitmap.compress(Bitmap.CompressFormat.WEBP, 100, outputStream)
val byteArray = outputStream.toByteArray()

>>> byteArray.size
if JPEG, 53673984
if PNG, 12702333
if WEBP, 9301538

사진 원래 크기 : 3.35MB

  • JPEG
    bitmap.byteCountbyteArray.size
    quality = 10053673984 (5.36MB)5433395 (5.4MB)
    quality = 5053673984 (5.36MB)534970 (534KB)
    quality = 053673984 (5.36MB)77290 (77KB)
    • 53.6MB → 5.4MB (약 9.8배 줄어듬)
      • quality = 50 ⇒ 5.4MB → 534KB로 줄어듬 (품질저하는 별로 없음)
    • 소요시간 : 1.2s
  • PNG
    bitmap.byteCountbyteArray.size
    quality = 10053673984 (53.6MB)12702333 (12.7MB)
    quality = 5053673984 (53.6MB)12702333 (12.7MB)
    quality = 053673984 (53.6MB)12702333 (12.7MB)
    • 53.6MB → 12.7MB (약 4.3배 줄어듬)
      • quality = 50 ⇒ 12.7MB 유지
    • 소요시간 : 4s
  • WEBP
    bitmap.byteCountbyteArray.size
    quality = 10053673984 (53.6MB)9301538 (9.3MB)
    quality = 5053673984 (53.6MB)241888 (241KB)
    quality = 053673984 (53.6MB)42976 (42KB)
    • 53.6MB → 9.3MB (약 5.7배 줄어듬)
      • quality = 50 ⇒ 9.3MB → 241KB로 줄어듬 (품질저하는 별로 없음)
    • 소요시간 : 4.8s

*compress quality 를 작게하면 이미지 크기를 줄일 수 있다.

*PNG 사용은 좋지 않다. 이미지를 로드할 때, 이미지 크기가 크다면 메모리를 차지하고 로딩되는 시간도 오래걸린다.

*WEBP는 Deprecated 되었다.

BitmapFactory 방법

ImageDecoder와 똑같은 사진으로 진행합니다. quality는 100으로 진행했습니다.

사진 원래 크기 : 3.35MB

  • JPEG
    bitmap.byteCountbyteArray.size
    qulity = 100 / inSampleSize = 148771072 (48.7MB)5435293 (5.34MB)
    qulity = 100 / inSampleSize = 212192768 (12.2MB)1774780 (1.7MB)
    qulity = 100 / inSampleSize = 43048192 (3MB)423594 (423KB)
    qulity = 50 / inSampleSize = 148771072 (48.7MB)534970 (534KB)
    qulity = 50 / inSampleSize = 212192768 (12.1MB)94848 (94KB)
    qulity = 50 / inSampleSize = 43048192 (3MB)25111 (25KB)

*PNG와 WEBP는 고려하지 않습니다.

이미지 회전하는 이슈 발생!

사진촬영 후, 갤러리에서 사진 uri를 가져와 BitmapFactory로 비트맵 전환 후, 이미지 바인딩 시 회전되어 보임.

이유는 단순하다. 카메라로 사진을 촬영할 때, 센서의 방향으로 사진을 저장하기 때문에 해당 사진을 비트맵으로 반환하니 그대로 보여주어서 회전된 사진이 보여집니다. 해당 코드는 BitmapFactory를 다룬 목차를 확인해주세요.

이슈의 해결은 다음과 같습니다. 비트맵의 메타데이터를 가져와 회전 속성을 확인합니다. 회전 속성이 올바르지 않다면 즉, 90도 회전, 180도 회전 등 회전되어있다면 올바르게 돌려야 합니다.
해결하기 위해서 ExifInterface API를 사용합니다. 이는 스트림을 통해 메타데이터를 가져올 수 있습니다.

아래의 코드는 이미지 uri에 대한 stream을 시작하여 사진의 orientation이 어떤 값을 갖는지 반환하는 함수입니다.

fun loadOrientation(photoUri: Uri): Int {
    var orientation: Int = 0
    try {
        val stream = contentResolver.openInputStream(photoUri)
        val exifInterface = ExifInterface(stream!!)
        orientation = exifInterface.getAttributeInt(
            ExifInterface.TAG_ORIENTATION,
            ExifInterface.ORIENTATION_NORMAL
        )
        stream.close()
    } catch (e: Exception) {
    }
    return orientation
}

다음으로는 이미지 uri에 대한 비트맵을 반환해야 합니다.

fun decodeUriToBitmap(uri: Uri, bounds: Boolean = false, size: Int = 1): Bitmap? {
    var bitmap: Bitmap? = null
    val options = BitmapFactory.Options().apply {
        inJustDecodeBounds = bounds
        inSampleSize = size
    }

    try {
        val stream = contentResolver.openInputStream(uri)
        bitmap = BitmapFactory.decodeStream(stream, null, options)

        stream?.close()
    } catch (e: Exception) {
    }

    return bitmap
}

*주의해야할 점이 존재합니다. openInputStream 으로 스트림을 열고 있습니다. 하나의 스트림을 사용할 때, 여러 곳에서 해당 스트림을 사용하려고 하면 첫 번째에서 사용된 곳에서 사용되는 것이지 다음 사용되는 곳에서는 null 이 사용됩니다.

예를 들어서, 다음과 같이 이미지 회전 속성 orientation 을 가져올 때와 비트맵을 만들 때 사용할 수 있습니다. 이 경우에는 ExifInterface 에서만 사용되고, BitmapFactory.decodeStream 부분에서는 stream은 null을 가지고 있습니다.

val stream = contentResolver.openInputStream(uri)
val exifInterface = ExifInterface(stream!!)
val bitmap = BitmapFactory.decodeStream(stream, null, options)

그 후에 이미지 회전 방향(orientation) 과 이미지 비트맵을 만들었으니 이미지 어떤 방향으로 얼마나 회전시킬 지 정해야 합니다. 다음과 같이 ExifInterface 멤버로 회전 방향이 정의되어 있습니다.

fun rotateBitmap(orientation: Int, bitmap: Bitmap?): Bitmap? = when (orientation) {
    ExifInterface.ORIENTATION_ROTATE_90 -> rotateImage(bitmap, 90f)
    ExifInterface.ORIENTATION_ROTATE_180 -> rotateImage(bitmap, 180f)
    ExifInterface.ORIENTATION_ROTATE_270 -> rotateImage(bitmap, 270f)
    else -> bitmap
} 

그 다음으로 이미지를 어떤 방향으로 회전시켜야할 지 정해졌으니, 실제로 이미지를 돌려야 합니다.

fun rotateImage(bitmap: Bitmap?, angle: Float): Bitmap? {
    val matrix = Matrix().apply { postRotate(angle) }
    return bitmap?.let {
        Bitmap.createBitmap(it, 0, 0, it.width, it.height, matrix, true)
    }
}

그래서 다음과 같은 이미지를 원하는 이미지 품질로 압축하면 회전된 이미지를 사용하지 않고 정상적으로 사용할 수 있습니다.

val orientation = loadOrientation(photoUri)
val bitmap = rotateBitmap(orientation, decodeUriToBitmap(photoUri))

val output = ByteArrayOutputStream()
bitmap?.compress(Bitmap.CompressFormat.JPEG, 100, output)
val byteArr = output.toByteArray()
>>> bytrArr.size 로 압축된 이미지의 크기 판별

binding.imageView.setImageBitmap(bitmap)

이미지 리사이즈 방법 고르기

보여지는 이미지가 크지 않다면, BitmapFactory를 이용해서 커스텀하여 이미지를 리사이징하는 방법이 좋아보인다. 확실하게 이미지의 크기를 많이줄일 수 있기 때문이다. 하지만 보이는 이미지의 크기가 어느정도 있다면 이미지의 품질을 고려해 ImageDecoder 방법이 좋아보인다.

추가적으로 ImageDecoder의 방법은 코드가 짧고 간단하기 때문에 이미지의 크기를 많이 줄이지 않아도 괜찮다면 좋은 방법일거라 생각합니다.

profile
Allright!

0개의 댓글

관련 채용 정보