CameraX 사용 사례 회전

이 주제에서는 ImageAnalysis 사용 사례든 ImageCapture 사용 사례든 앱에서 CameraX 사용 사례를 설정하여 올바른 회전 정보가 포함된 이미지를 가져오는 방법을 설명합니다. 따라서 다음과 같은 전략을 구사하세요.

  • ImageAnalysis 사용 사례의 Analyzer는 올바른 회전이 적용된 프레임을 수신해야 합니다.
  • ImageCapture 사용 사례에서는 올바른 회전으로 사진을 촬영해야 합니다.

용어

이 주제에서는 다음과 같은 용어를 사용하므로 각 용어의 의미를 이해하는 것이 중요합니다.

디스플레이 방향
기기의 어느 측면이 위를 향하는지를 나타냅니다. 값은 4가지 즉, 세로, 가로, 세로 반전, 가로 반전 중 하나일 수 있습니다.
디스플레이 회전
Display.getRotation()에서 반환된 값으로, 기기가 자연스러운 방향에서 시계 반대 방향으로 회전한 각도를 나타냅니다.
타겟 회전
자연스러운 방향에 이르도록 기기를 시계 방향으로 회전시킬 각도를 나타냅니다.

타겟 회전을 결정하는 방법

다음 예는 자연스러운 방향을 기준으로 기기의 타겟 회전을 결정하는 방법을 보여줍니다.

예 1: 자연스러운 세로 방향

기기 예: Pixel 3 XL

자연스러운 방향 = 세로
현재 방향 = 세로

디스플레이 회전 = 0
타겟 회전 = 0

자연스러운 방향 = 세로
현재 방향 = 가로

디스플레이 회전 = 90
타겟 회전 = 90

예 2: 자연스러운 가로 방향

기기 예: Pixel C

자연스러운 방향 = 가로
현재 방향 = 가로

디스플레이 회전 = 0
타겟 회전 = 0

자연스러운 방향 = 가로
현재 방향 = 세로

디스플레이 회전 = 270
타겟 회전 = 270

이미지 회전

어느 쪽이 위를 향하나요? 센서 방향은 Android에서 상수 값으로 정의됩니다. 이 값은 기기가 자연스러운 위치에 있을 때 센서가 기기 상단에서 회전한 각도(0, 90, 180, 270)를 나타냅니다. 도표의 모든 사례에 나와 있는 이미지 회전에서는 데이터를 똑바로 표시하기 위해 시계 방향으로 어떻게 회전해야 하는지를 설명합니다.

다음 예에서는 카메라 센서 방향에 따라 이미지 회전이 어떻게 이루어져야 하는지를 보여줍니다. 또한 타겟 회전이 디스플레이 회전으로 설정되어 있다고 가정합니다.

예 1: 센서가 90도 회전함

기기 예: Pixel 3 XL

디스플레이 회전 = 0
디스플레이 방향 = 세로
이미지 회전 = 90

디스플레이 회전 = 90
디스플레이 방향 = 가로
이미지 회전 = 0

예 2: 센서가 270도 회전함

기기 예: Nexus 5X

디스플레이 회전 = 0
디스플레이 방향 = 세로
이미지 회전 = 270

디스플레이 회전 = 90
디스플레이 방향 = 가로
이미지 회전 = 180

예 3: 센서가 0도 회전함

기기 예: Pixel C(태블릿)

디스플레이 회전 = 0
디스플레이 방향 = 가로
이미지 회전 = 0

디스플레이 회전 = 270
디스플레이 방향 = 세로
이미지 회전 = 90

이미지 회전 계산

ImageAnalysis

ImageAnalysisAnalyzer는 카메라의 이미지를 ImageProxy 형태로 수신합니다. 각 이미지에는 다음을 통해 액세스할 수 있는 회전 정보가 포함되어 있습니다.

val rotation = imageProxy.imageInfo.rotationDegrees

이 값은 ImageAnalysis의 타겟 회전과 일치하도록 이미지를 시계 방향으로 회전시켜야 하는 각도를 나타냅니다. Android 앱의 컨텍스트에서 ImageAnalysis의 타겟 회전은 일반적으로 화면의 방향과 일치합니다.

ImageCapture

콜백은 ImageCapture 인스턴스에 연결되어 캡처 결과가 준비되면 신호를 보냅니다. 결과는 캡처된 이미지이거나 오류일 수 있습니다.

사진을 찍을 때 제공되는 콜백은 다음 유형 중 하나일 수 있습니다.

  • OnImageCapturedCallback: 메모리 내 액세스 권한이 있는 이미지를 ImageProxy 형태로 수신합니다.
  • OnImageSavedCallback: 캡처된 이미지가 ImageCapture.OutputFileOptions로 지정된 위치에 성공적으로 저장되면 호출됩니다. 옵션은 File, OutputStream을 지정할 수도 있고 MediaStore로 위치를 지정할 수 있습니다.

어떤 형식이든(ImageProxy, File, OutputStream, MediaStore Uri) 캡처된 이미지의 회전은 캡처된 이미지를 ImageCapture의 타겟 회전(일반적으로 Android 앱의 컨텍스트에서는 화면의 방향과 일치)과 일치시키기 위해 시계 방향으로 회전시켜야 하는 각도를 나타냅니다.

다음 방법 중 하나로 캡처된 이미지의 회전을 가져올 수 있습니다.

ImageProxy

val rotation = imageProxy.imageInfo.rotationDegrees

File

val exif = Exif.createFromFile(file)
val rotation = exif.rotation

OutputStream

val byteArray = outputStream.toByteArray()
val exif = Exif.createFromInputStream(ByteArrayInputStream(byteArray))
val rotation = exif.rotation

MediaStore uri

val inputStream = contentResolver.openInputStream(outputFileResults.savedUri)
val exif = Exif.createFromInputStream(inputStream)
val rotation = exif.rotation

이미지 회전 확인

ImageAnalysisImageCapture 사용 사례에서는 캡처 요청이 성공하면 카메라에서 ImageProxy를 수신합니다. ImageProxy는 회전 정보를 포함하여 이미지와 이미지 정보를 래핑합니다. 이 회전 정보는 사용 사례의 타겟 회전과 일치하도록 이미지를 회전시켜야 하는 각도를 나타냅니다.

이미지의 회전 확인 흐름

ImageCapture/ImageAnalysis 타겟 회전 가이드라인

대부분의 기기가 기본적으로 세로 반전 또는 가로 반전으로 회전되지 않기 때문에 일부 Android 앱에서는 이러한 방향을 지원하지 않습니다. 앱의 지원 여부에 따라 사용 사례의 타겟 회전 업데이트 방식이 달라집니다.

아래 두 가지 표에는 사용 사례의 타겟 회전을 디스플레이 회전과 동기화 상태로 유지하는 방법이 나와 있습니다. 첫 번째 표에는 네 가지 방향을 모두 지원하면서 동기화 상태를 유지하는 방법이 나와 있습니다. 두 번째 표에는 기기가 기본적으로 회전하는 방향만 처리하는 경우가 나와 있습니다.

앱에서 어떤 가이드라인을 따를지 선택하려면 다음 단계를 수행하세요.

  1. 앱의 카메라 Activity 방향이 잠겨 있는지, 잠금 해제되어 있는지 아니면 방향 구성 변경을 재정의하는지 확인합니다.

  2. 앱의 카메라 Activity에서 네 가지의 모든 기기 방향(세로, 세로 반전, 가로, 가로 반전)을 모두 처리하도록 할지 아니면 앱이 실행되는 기기가 기본적으로 지원하는 방향만 처리하도록 할지 결정합니다.

네 가지 방향 모두 지원

이 표에는 기기가 세로 반전으로 회전되지 않는 경우에 따라야 할 특정 가이드라인이 나와 있습니다. 가로 반전으로 회전되지 않는 기기에도 동일하게 적용될 수 있습니다.

시나리오 가이드라인 단일 창 모드 멀티 윈도우 화면 분할 모드
방향이 잠금 해제됨 ActivityonCreate() 콜백 등에서 Activity가 생성될 때마다 사용 사례를 설정합니다.
OrientationEventListeneronOrientationChanged()를 사용합니다. 콜백 내에서 사용 사례의 타겟 회전을 업데이트합니다. 이는 기기가 180도 회전하는 등 방향이 변경된 후에도 시스템에서 Activity를 다시 생성하지 않는 사례를 처리합니다. 디스플레이가 세로 반전 방향일 때 기기가 기본적으로 세로 반전으로 회전되지 않는 경우도 처리됩니다. 기기가 회전(예: 90도)할 때 Activity가 다시 생성되지 않는 사례도 처리됩니다. 이 같은 경우는 앱이 화면의 절반까지 차지하는 소형 폼 팩터 기기와 앱이 화면의 2/3까지 차지하는 대형 기기에서 발생합니다.
선택사항: AndroidManifest 파일에서 ActivityscreenOrientation 속성을 fullSensor로 설정합니다. 기기가 세로 반전 방향일 때 UI가 똑바로 표시되며 기기가 90도 회전할 때마다 시스템에서 Activity가 다시 생성됩니다. 기본적으로 세로 반전으로 회전하지 않는 기기에는 아무런 영향을 미치지 않습니다. 디스플레이가 세로 반전 방향일 때는 멀티 윈도우 모드가 지원되지 않습니다.
방향이 잠김 ActivityonCreate() 콜백 등에서 Activity가 처음 생성될 때 사용 사례를 한 번만 설정합니다.
OrientationEventListeneronOrientationChanged()를 사용합니다. 콜백 내에서 미리보기를 제외한 사용 사례의 타겟 회전을 업데이트합니다. 기기가 회전(예: 90도)할 때 Activity가 다시 생성되지 않는 사례도 처리됩니다. 이 같은 경우는 앱이 화면의 절반까지 차지하는 소형 폼 팩터 기기와 앱이 화면의 2/3까지 차지하는 대형 기기에서 발생합니다.
방향 구성 변경이 재정의됨 ActivityonCreate() 콜백 등에서 Activity가 처음 생성될 때 사용 사례를 한 번만 설정합니다.
OrientationEventListeneronOrientationChanged()를 사용합니다. 콜백 내에서 사용 사례의 타겟 회전을 업데이트합니다. 기기가 회전(예: 90도)할 때 Activity가 다시 생성되지 않는 사례도 처리됩니다. 이 같은 경우는 앱이 화면의 절반까지 차지하는 소형 폼 팩터 기기와 앱이 화면의 2/3까지 차지하는 대형 기기에서 발생합니다.
선택사항: AndroidManifest 파일에서 Activity의 screenOrientation 속성을 fullSensor로 설정합니다. 기기가 세로 반전일 때 UI가 똑바로 표시됩니다. 기본적으로 세로 반전으로 회전하지 않는 기기에는 아무런 영향을 미치지 않습니다. 디스플레이가 세로 반전 방향일 때는 멀티 윈도우 모드가 지원되지 않습니다.

기기에서 가능한 방향만 지원

기기에서 기본적으로 지원되는 방향만 지원합니다(세로 반전/가로 반전이 포함되거나 포함되지 않을 수 있음).

시나리오 가이드라인 멀티 윈도우 화면 분할 모드
방향이 잠금 해제됨 ActivityonCreate() 콜백 등에서 Activity가 생성될 때마다 사용 사례를 설정합니다.
DisplayListeneronDisplayChanged()를 사용합니다. 콜백 내에서 사용 사례의 타겟 회전(예: 기기가 180도 회전할 때)을 업데이트합니다. 기기가 회전(예: 90도)할 때 Activity가 다시 생성되지 않는 사례도 처리됩니다. 이 같은 경우는 앱이 화면의 절반까지 차지하는 소형 폼 팩터 기기와 앱이 화면의 2/3까지 차지하는 대형 기기에서 발생합니다.
방향이 잠김 ActivityonCreate() 콜백 등에서 Activity가 처음 생성될 때 사용 사례를 한 번만 설정합니다.
OrientationEventListeneronOrientationChanged()를 사용합니다. 콜백 내에서 사용 사례의 타겟 회전을 업데이트합니다. 기기가 회전(예: 90도)할 때 Activity가 다시 생성되지 않는 사례도 처리됩니다. 이 같은 경우는 앱이 화면의 절반까지 차지하는 소형 폼 팩터 기기와 앱이 화면의 2/3까지 차지하는 대형 기기에서 발생합니다.
방향 구성 변경이 재정의됨 ActivityonCreate() 콜백 등에서 Activity가 처음 생성될 때 사용 사례를 한 번만 설정합니다.
DisplayListeneronDisplayChanged()를 사용합니다. 콜백 내에서 사용 사례의 타겟 회전(예: 기기가 180도 회전할 때)을 업데이트합니다. 기기가 회전(예: 90도)할 때 Activity가 다시 생성되지 않는 사례도 처리됩니다. 이 같은 경우는 앱이 화면의 절반까지 차지하는 소형 폼 팩터 기기와 앱이 화면의 2/3까지 차지하는 대형 기기에서 발생합니다.

방향이 잠금 해제됨

Activity는 세로 또는 가로 같은 디스플레이 방향이 기기의 물리적 방향과 일치할 때 방향이 잠금 해제됩니다. 단, 일부 기기에서 기본적으로 지원하지 않는 세로 반전/가로 반전은 제외됩니다. 기기가 4개의 모든 방향으로 회전하도록 강제하려면 ActivityscreenOrientation 속성을 fullSensor로 설정합니다.

멀티 윈도우 모드에서 기본적으로 세로 반전/가로 반전을 지원하지 않는 기기는 screenOrientation 속성이 fullSensor로 설정되더라도 세로 반전/가로 반전으로 회전하지 않습니다.

<!-- The Activity has an unlocked orientation, but might not rotate to reverse
portrait/landscape in single-window mode if the device doesn't support it by
default. -->
<activity android:name=".UnlockedOrientationActivity" />

<!-- The Activity has an unlocked orientation, and will rotate to all four
orientations in single-window mode. -->
<activity
   android:name=".UnlockedOrientationActivity"
   android:screenOrientation="fullSensor" />

방향이 잠김

디스플레이는 기기의 물리적 방향과 관계없이 동일한 디스플레이 방향(세로나 가로 등)에 계속 놓이면 방향이 잠깁니다. 이렇게 하려면 AndroidManifest.xml 파일의 선언 내에 ActivityscreenOrientation 속성을 지정하면 됩니다.

디스플레이 방향이 잠기면 기기가 회전할 때 시스템은 Activity를 삭제하거나 다시 생성하지 않습니다.

<!-- The Activity keeps a portrait orientation even as the device rotates. -->
<activity
   android:name=".LockedOrientationActivity"
   android:screenOrientation="portrait" />

방향 구성 변경이 재정의됨

Activity가 방향 구성 변경을 재정의하면 기기의 물리적 방향이 변경될 때 시스템은 이 요소를 삭제하거나 다시 생성하지 않습니다. 하지만 시스템은 기기의 물리적 방향과 일치하도록 UI를 업데이트합니다.

<!-- The Activity's UI might not rotate in reverse portrait/landscape if the
device doesn't support it by default. -->
<activity
   android:name=".OrientationConfigChangesOverriddenActivity"
   android:configChanges="orientation|screenSize" />

<!-- The Activity's UI will rotate to all 4 orientations in single-window
mode. -->
<activity
   android:name=".OrientationConfigChangesOverriddenActivity"
   android:configChanges="orientation|screenSize"
   android:screenOrientation="fullSensor" />

카메라 사용 사례 설정

위에 설명된 시나리오에서는 Activity가 처음 생성될 때 카메라 사용 사례를 설정할 수 있습니다.

방향이 잠금 해제된 Activity의 경우 방향 변경 시 시스템이 Activity를 삭제하고 다시 생성하기 때문에 기기가 회전할 때마다 이 설정이 수행됩니다. 그 결과 사용 사례에서 그때마다 디스플레이 방향과 기본적으로 일치하도록 타겟 회전이 설정됩니다.

방향이 잠겨 있거나 방향 구성 변경을 재정의하는 Activity의 경우 Activity가 처음 생성될 때 이 설정이 한 번 실행됩니다.

class CameraActivity : AppCompatActivity() {
   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)

       val cameraProcessFuture = ProcessCameraProvider.getInstance(this)
       cameraProcessFuture.addListener(Runnable {
          val cameraProvider = cameraProcessFuture.get()

          // By default, the use cases set their target rotation to match the
          // display’s rotation.
          val preview = buildPreview()
          val imageAnalysis = buildImageAnalysis()
          val imageCapture = buildImageCapture()

          cameraProvider.bindToLifecycle(
              this, cameraSelector, preview, imageAnalysis, imageCapture)
       }, mainExecutor)
   }
}

OrientationEventListener 설정

OrientationEventListener를 사용하면 기기 방향이 변경될 때 카메라 사용 사례의 타겟 회전을 지속적으로 업데이트할 수 있습니다.

class CameraActivity : AppCompatActivity() {

    private val orientationEventListener by lazy {
        object : OrientationEventListener(this) {
            override fun onOrientationChanged(orientation: Int) {
                if (orientation == ORIENTATION_UNKNOWN) {
                    return
                }

                val rotation = when (orientation) {
                     in 45 until 135 -> Surface.ROTATION_270
                     in 135 until 225 -> Surface.ROTATION_180
                     in 225 until 315 -> Surface.ROTATION_90
                     else -> Surface.ROTATION_0
                 }

                 imageAnalysis.targetRotation = rotation
                 imageCapture.targetRotation = rotation
            }
        }
    }

    override fun onStart() {
        super.onStart()
        orientationEventListener.enable()
    }

    override fun onStop() {
        super.onStop()
        orientationEventListener.disable()
    }
}

DisplayListener 설정

DisplayListener를 사용하면, 기기가 180도 회전한 다음 시스템에서 Activity를 삭제하거나 다시 생성하지 않는 특정 상황 등에서 카메라 사용 사례의 타겟 회전을 업데이트할 수 있습니다.

class CameraActivity : AppCompatActivity() {

    private val displayListener = object : DisplayManager.DisplayListener {
        override fun onDisplayChanged(displayId: Int) {
            if (rootView.display.displayId == displayId) {
                val rotation = rootView.display.rotation
                imageAnalysis.targetRotation = rotation
                imageCapture.targetRotation = rotation
            }
        }

        override fun onDisplayAdded(displayId: Int) {
        }

        override fun onDisplayRemoved(displayId: Int) {
        }
    }

    override fun onStart() {
        super.onStart()
        val displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
        displayManager.registerDisplayListener(displayListener, null)
    }

    override fun onStop() {
        super.onStop()
        val displayManager = getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
        displayManager.unregisterDisplayListener(displayListener)
    }
}