작업 일지

[Android] ExifInterface와 Matrix로 이미지 회전 시키기

O_Gyong 2022. 11. 29.

프로필 이미지를 변경하면 서버에 uri를 전달하고, 서버에서 이미지의 새 uri를 받아서 Glide를 통해 보여주고 있었다.

 

문제는 갤러리에서 선택한 캡처와 다운로드된 이미지는 프로필에 정상적으로 등록이 됐는데, 폰으로 촬영한 이미지의 경우에는 등록된 프로필 이미지를 보면 사진이 회전되어 있었다.

 

ExifInterface와 Matrix를 이용하면 해결할 수 있다고 한다.

해결 과정은 아래와 같다.

1. ExifInterface로 이미지의 회전 정보를 알아낸다.
2. Matrix를 이용해 회전한 값만큼 회전시킨다.
3. 새로운 Bitmap을 만들고 Matrix를 적용시킨다.
4. Bitmap을 파일에 저장하여 uri 정보를 얻는다.
5. 새로 얻은 uri를 서버에 전달한다.

이미지 회전 예제

예제로 회전된 이미지를 90도로 고정하여 보여주려고 한다.

(서버 없이 그냥 파일을 올리는 것으로 이슈가 재현이 안돼서 이미지를 그냥 회전시키는 것으로..)

1. gradle에 Glide 의존성 추가
2. Manifest에 파일 읽기, 쓰기 권한 등록
3. 권한 처리
4. 갤러리 화면으로 이동
5. 이미지 회전

Glide 의존성 추가 및 파일 권한 등록

// glide
implementation 'com.github.bumptech.glide:glide:4.13.2'
<!--  갤러리 권한  -->
<uses-permission 
    android:name="android.permission.WRITE_EXTERNAL_STORAGE"
    tools:ignore="ScopedStorage" />
<uses-permission 
    android:name="android.permission.READ_EXTERNAL_STORAGE" />

권한 처리

/**
 * 파일 접근 권한 체크(갤러리 진입)
 */
private fun checkPermission() {
    if(checkSelfPermission(galleryPermission) == PackageManager.PERMISSION_DENIED) {
        // 권한 거절 시 권한 요청
        requestPermissions(arrayOf(galleryPermission), 1)
    }else{
        // 권한 허용 시 갤러리로 이동
        intentGallery()
    }
}

/**
 * 권한 요청 결과 처리
 */
override fun onRequestPermissionsResult(
    requestCode: Int,
    permissions: Array<out String>,
    grantResults: IntArray
) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults)

    if(requestCode == 1) {
        if(grantResults[0] == PackageManager.PERMISSION_GRANTED){
            intentGallery()
        }else{
            Toast.makeText(this, "권한 거절됨", Toast.LENGTH_SHORT).show()
        }
    }
}

갤러리로 이동하는 버튼을 클릭 시 checkPermission 메서드를 호출하도록 했다.

권한이 거절되면 토스트 팝업을 띄우고, 허용 시에만 갤러리로 이동하도록 처리했다.

(다시 묻지 않음은 처리 안 함)


갤러리 화면으로 이동

/**
 * 갤러리 화면으로 이동
 */
private fun intentGallery() {
    // Intent의 Action 값으로 ACTION_GET_CONTENT, ACTION_PICK 둘 중 하나 사용
    val intent = Intent(Intent.ACTION_PICK)
    intent.type = MediaStore.Images.Media.CONTENT_TYPE
    intent.setDataAndType(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, "image/*")
    requestGalleryLauncher.launch(intent)
}

Intent의 Action 값으로 ACTION_GET_CONTENT 또는 ACTION_PICK을 사용한다.

두 가지 Action을 통해서 갤러리에서 이미지를 선택했을 때 데이터를 얻을 수 있다.


이미지 회전

/**
 * 갤러리 화면에서 이미지 선택 이후 처리
 */
private val requestGalleryLauncher =
    registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
        if(it.resultCode == RESULT_OK) {

            // 선택한 이미지의 uri가 null 이면 종료
            if(it.data?.data == null ){
                return@registerForActivityResult
            }

            var uri = it.data?.data!!

            // 원본 이미지 등록
            Glide.with(this)
                .load(uri)
                .apply(
                    // 이전 이미지를 재활용하지 않도록 처리
                    RequestOptions()
                        .diskCacheStrategy(DiskCacheStrategy.NONE)
                        .skipMemoryCache(true)
                )
                .into(mBinding.ivMain1)


            try {
                // uri와 연결된 콘텐츠에 대한 스트림을 연다.
                // → uri를 이용해 이미지 데이터를 onpenInputStream으로 얻는다.
                var inputStream = contentResolver.openInputStream(uri) ?: return@registerForActivityResult

                // 이미지 파일을 읽기 위해 ExifInterface에 InputStream 정보를 넘겨줌
                val exif = ExifInterface(inputStream)

                // 회전 정보 알아내기
                var orientation =
                    exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
                when (orientation) {
                    ExifInterface.ORIENTATION_ROTATE_90 -> orientation = 90
                    ExifInterface.ORIENTATION_ROTATE_180 -> orientation = 180
                    ExifInterface.ORIENTATION_ROTATE_270 -> orientation = 270
                }

                // 180도 이상 회전된 이미지를 90도로 맞춰줌
                if(orientation >= 180){

                    // InputStream은 사용할 경우 0을 반환하기 때문에 재정의를 해줘야한다.
                    inputStream = contentResolver.openInputStream(uri) ?: return@registerForActivityResult

                    // InputStream을 Bitmap으로 디코딩 (Bitmap을 생성)
                    val bitmap = BitmapFactory.decodeStream(inputStream)

                    // InputStream을 닫고 연결된 모든 리소스를 해제
                    inputStream.close()

                    // Matrix를 이용해 이미지 회전 → 90도로 고정
                    val matrix = Matrix()
                    matrix.setRotate(90f, bitmap.width.toFloat(), bitmap.height.toFloat())

                    // 회전한 Matrix 정보로 새 Bitmap 생성
                    val newImg = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)

                    // 캐시에 임시 파일 생성
                    val cacheFile = File(
                        applicationContext.cacheDir,
                        "newImage.jpg"
                    )

                    // 임시 파일에 이미지를 저장하면서 새로운 uri 경로를 얻어옴
                    val outputStream = FileOutputStream(cacheFile)
                    newImg.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
                    outputStream.close()

                    uri = cacheFile.toUri()
                }
            } catch (e: IOException) {
                e.printStackTrace()
            }

            // 수정본 이미지 등록
            Glide.with(this)
                .load(uri)
                .apply(
                    // 이전 이미지를 재활용하지 않도록 처리
                    RequestOptions()
                        .diskCacheStrategy(DiskCacheStrategy.NONE)
                        .skipMemoryCache(true)
                )
                .into(mBinding.ivMain2)

            mBinding.ivMain1.visibility = View.VISIBLE
            mBinding.ivMain2.visibility = View.VISIBLE
            mBinding.tvMain1.visibility = View.VISIBLE
            mBinding.tvMain2.visibility = View.VISIBLE
        }
    }

갤러리에서 이미지를 선택하고 registerForActivityResult에서 그 결과를 처리한 전체 코드다.

url이 null일 때의 처리와 Glide에 이미지를 등록하고 View의 visibility를 처리하는 코드가 있어서 코드가 좀 길다.

 

이미지 회전을 처리한 부분은 try·catch 구문부터이다.


ExifInterface로 이미지 회전 정보 알아내기
// uri와 연결된 콘텐츠에 대한 스트림을 연다.
// → uri를 이용해 이미지 데이터를 onpenInputStream으로 얻는다.
var inputStream = contentResolver.openInputStream(uri) ?: return@registerForActivityResult

// 이미지 파일을 읽기 위해 ExifInterface에 InputStream 정보를 넘겨줌
val exif = ExifInterface(inputStream)

// 회전 정보 알아내기
var orientation =
    exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
when (orientation) {
    ExifInterface.ORIENTATION_ROTATE_90 -> orientation = 90
    ExifInterface.ORIENTATION_ROTATE_180 -> orientation = 180
    ExifInterface.ORIENTATION_ROTATE_270 -> orientation = 270
}

기기에 저장된 이미지 정보는 ContentProvider로부터 얻을 수 있다.

 

(url 경로 : 'content://'로 시작함)

 

contentResolver를 사용하면 ContentProvider에 접근할 수 있고,

openInputStream에 uri 정보를 넘겨줘서 이미지 데이터 정보를 얻는다.

InputStream 참고 자료

 

ExifInterface는 다양한 이미지 파일들의 데이터를 읽거나 쓰는 클래스이다.

InputStream을 전달받아 객체를 생성하고, getAttributeInt에 회전 태그 값을 넘겨주면 이미지의 회전 데이터를 반환한다.

(두 번째 인자는 default 값으로 첫 번째 인자 값에 문제가 있을 경우 두 번째 값으로 처리한다.)

ExifInterface 참고 자료


Matrix로 이미지 회전 및 새로운 Bitmap 생성
// 180도 이상 회전된 이미지를 90도로 맞춰줌
    if(orientation >= 180){

        // InputStream은 사용할 경우 0을 반환하기 때문에 재정의를 해줘야한다.
        inputStream = contentResolver.openInputStream(uri) ?: return@registerForActivityResult

        // InputStream을 Bitmap으로 디코딩 (Bitmap을 생성)
        val bitmap = BitmapFactory.decodeStream(inputStream)

        // InputStream을 닫고 연결된 모든 리소스를 해제
        inputStream.close()

        // Matrix를 이용해 이미지 회전 → 90도로 고정
        val matrix = Matrix()
        matrix.setRotate(90f, bitmap.width.toFloat(), bitmap.height.toFloat())

        // 회전한 Matrix 정보로 새 Bitmap 생성
        val newImg = Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)

        // 캐시에 임시 파일 생성
        val cacheFile = File(
            applicationContext.cacheDir,
            "newImage.jpg"
        )

        // 임시 파일에 이미지를 저장하면서 새로운 uri 경로를 얻어옴
        val outputStream = FileOutputStream(cacheFile)
        newImg.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
        outputStream.close()

        uri = cacheFile.toUri()
    }
} catch (e: IOException) {
    e.printStackTrace()
}

InputStream은 한 번 사용하고 나면 값이 날아간다고 한다.

Bitmap을 그리기 위해서 InputStream을 넘겨줄 때 재설정을 하자.

(재설정을 하지 않으면 Bitmap의 width와 height가 null로 되어있어 Matrix의 rotate에서 앱이 종료된다.)

 

Matrix()의 setRotate를 사용하여 90도로 회전시켜준다.

참고로 회전 방법에는 세 가지 함수가 있다.

  • setRotate : 기존 행렬의 회전 값을 초기화하고 회전 행렬 값 적용
  • preRotate : 기존 행렬의 회전 값에 회전 행렬 적용
  • postRotate : 회전 행렬에 기존 행렬 값 적용

 

위에서 구한 Matrix를 이용해 새로운 Bitmap을 만든다.

Bitmap을 캐시에 저장하여 uri 경로를 구할 수 있도록 하고, Glide에 새 uri를 적용했다.

(캐시 삭제는 구현 안 함)


전체 코드

댓글