Galaxy 수채화 현상 완전 정복! Raw Image 를 활용한 Android Camera Module 개발 [1/3]
Galaxy S22/S23 수채화 현상으로 헬스케어 앱의 키트 판독 정확도가 15%까지 떨어졌다. CameraX에서 Camera2 API로 전환하고 Raw Image 처리 기반 커스텀 Camera Module을 개발해 판독 정확도를 95%까지 향상시켰다. 이 시리즈에서는 문제 분석부터 해결 과정, 성능 최적화까지 전 과정을 다룬다.
Galaxy S22 이후부터 수채화 현상이 나타나는 경우가 많이 있다. 정확히 말하면 글자가 뭉개지는 느낌, 사물의 윤곽이 흐릿한 느낌 등을 이야기하는 것이다. 과도한 후처리(Post-processing) 알고리즘을 사용하고 있을 것이다.(아래 링크 참조) 아메리카노 한잔 앞에 두고 찍는 셀카는 잡티가 뭉개져야만 더 아름답게 나올수도 있을 것이다. 안타깝게도 지금 회사 앱에서는 디테일이 생명이었다.

임신을 준비하는 여성들을 위해 작은 키트를 카메라로 판독하는 Andorid Camera Module 을 개발 중이었다. 개발 도중, 이마에 진땀 홍수가 났다. 이유는, Galaxy S22 이후의 모델에서 약양성 키트를 대부분 음성으로 판독하는 것이다. 당시(2024년 8월) 아이폰 같은 경우는 거의 100% 확률로 약양성을 캐치해 냈는데, 유독 Galaxy S22 이후 시리즈 에서만 평균 15% 만 약양성으로 판독하는 것이다.
정해진 시간 내에 무조건 해결해야 했다. 개발 능력도 중요했지만, 현재 회사의 바이오 전문가 분들과의 소통, 해결하기 위해 깊이있는 Searching 능력(Diggin?)이 필요했다. 눈 앞에 안개는 자욱했다.
결국엔 해결했다. 95% 까지 정확도를 높였으며, 앱은 정해진 시기에 론칭을 했다. 개발 라이프 가운데 생각보다 뿌듯한 이 고군분투 과정을 나누면 좋을 것 같았다. 안개 자욱한 이 문제에 자그마한 표지판을 두면 사고는 덜 할 것이다. 글의 흐름은 다음과 같다.
1번: Raw Image (DNG) 활용이 '수채화 현상' 의 해답
2번: Camera2 API 활용, SurfaceView Customize 하기, DNG 활용
3번: DNGCreator 활용, Galaxy A 시리즈의 한계.
4번: ArucoMarker 적용 및 회고
Galaxy 수채화 현상 완전 정복! Raw Image 를 활용한 Android Camera Module 개발[1/3] 에서는 Raw Image 와 Camera2 API 활용 방법(1번, 2번) 에 이야기 하고, 3번은[2/3] 에서, 4번은 [3/3] 에서 이야기하겠다.
Raw Image 를 가져오는 것이, 내가 찾은 해결책이다. Raw Image 를 가져오기 위해서는 Android 최신 Camera Library 인 CameraX 를 사용하지 않고, Camera2 API를 사용해야 한다. 최신 라이브러리에서 해결될 거 같은 데 왜 예전 Library 를 사용하느냐고 묻는 사람이 있을 것이다. 아니다. 자동차에 비유하면 CameraX 는 자동변속기, CameraX 는 수동 변속기다. Raw 데이터 접근, 각 프레임의 노출 시간 수동 조절, 렌즈 포커스 거리 직접 제어같은 경우는 Camera2 에서 할 수 있다. CameraX는 Camera2 '기반' 으로 만들어졌다.
// 카메라의 정보를 담기 위한 간단한 데이터 클래스
data class CameraCapability(
val cameraId: String,
val orientation: String,
val supportsRaw: Boolean
)
/**
* 기기에서 사용 가능한 모든 카메라를 순회하며
* RAW 지원 여부를 포함한 주요 정보를 반환합니다.
*
* @param cameraManager 시스템의 CameraManager 인스턴스
* @return 각 카메라의 기능 정보가 담긴 List
*/
fun enumerateCameras(cameraManager: CameraManager): List<CameraCapability> {
val cameraList = mutableListOf<CameraCapability>()
// 1. Camera2 API와 호환되는 카메라 ID 목록만 필터링합니다.
// 이는 오래된 기기와의 호환성을 확보하는 좋은 습관입니다.
val compatibleCameraIds = cameraManager.cameraIdList.filter { cameraId ->
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
val capabilities = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)
capabilities?.contains(CameraMetadata.REQUEST_AVAILABLE_CAPABILITIES_BACKWARD_COMPATIBLE) ?: false
}
// 2. 호환 카메라 목록을 순회하며 각 카메라의 특성을 확인합니다.
compatibleCameraIds.forEach { cameraId ->
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
val orientation = getLensOrientationString(
characteristics.get(CameraCharacteristics.LENS_FACING)
)
// 3. 핵심: RAW 지원 여부를 '이중'으로 확인합니다.
val capabilities = characteristics.get(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES)!!
val outputFormats = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)!!.outputFormats!!
// 조건 1: 카메라의 기능 목록에 'RAW 기능'이 포함되어 있는가?
val hasRawCapability = capabilities.contains(CameraCharacteristics.REQUEST_AVAILABLE_CAPABILITIES_RAW)
// 조건 2: 카메라의 출력 포맷에 'RAW_SENSOR 포맷'이 포함되어 있는가?
val canOutputRaw = outputFormats.contains(ImageFormat.RAW_SENSOR)
val supportsRaw = hasRawCapability && canOutputRaw
if (supportsRaw) {
Log.d("CameraCheck", "카메라($cameraId)는 RAW를 지원합니다.")
}
cameraList.add(
CameraCapability(
cameraId = cameraId,
orientation = orientation,
supportsRaw = supportsRaw
)
)
}
return cameraList
}
/**
* 카메라 렌즈 방향(facing) 값을 문자열로 변환하는 헬퍼 함수
*/
private fun getLensOrientationString(facing: Int?): String = when (facing) {
CameraCharacteristics.LENS_FACING_BACK -> "Back"
CameraCharacteristics.LENS_FACING_FRONT -> "Front"
CameraCharacteristics.LENS_FACING_EXTERNAL -> "External"
else -> "Unknown"
}위 코드는 Raw 지원 여부를 확인하는 코드다. 가장 중요한 것 중에 하나는 이중으로 확인 로직을 개발해야 한다. 1번은 기능 플래그 확인이다. REQUEST_AVAILABLE_CAPABILITIES 목록 안에 REQUEST_AVAILABLE_CAPABILITIES_RAW 플래그가 있는 지 확인한다. 이것은 제조사가 "우리는 카메라 RAW 기능을 지원합니다" 라고 공식적으로 이야기 하는 것이다. 2번은 출력 포맷 확인이다. SCALER_STREAM_CONFIGURATION_MAP 에서 실제로 이 카메라가 출력할 수 있는 이미지 포맷 목록(outputFormats) 을 가져온다. 그리고 이 목록에 ImageFormat.RAW_SENSOR 가 있는지 확인한다. 참고로 orientation 을 확인 하는 이유는 뒷면 카메라만 사용하기 때문에 해당 내용도 넣어뒀다. 위 코드를 바탕으로 Raw Image 가능하면, Camera2Fragment 에서 Camera Module 을 실행했고, Raw Image 가 불가능하면, CameraXFragment 에서 실행했다. (추후에 Galaxy A 관련한 내용을 이야기하면서 구체적으로 나누겠다.)
Camera2 API 는 SurfaceView 를 사용해야 한다. SurfaceView 는 날 것 그대로의 캔버스라고 생각하면 된다. "날 것 그대로" 이기 때문에, 개발자가 직접 생명 주기를 관리하고, 카메라 데이터 스트림을 수동으로 연결해줘야 한다.
/**
* 지정된 화면 비율에 맞게 크기가 자동으로 조절되는 SurfaceView.
* 카메라 프리뷰의 왜곡 현상을 방지하기 위해 사용됩니다.
*/
class AutoFitSurfaceView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyle: Int = 0
) : SurfaceView(context, attrs, defStyle) {
private var aspectRatio = 0f
/**
* 이 뷰가 유지해야 할 화면 비율을 설정합니다.
* onMeasure()에서 이 비율을 기반으로 뷰의 크기를 다시 계산합니다.
*
* @param width 카메라 프리뷰의 원본 너비
* @param height 카메라 프리뷰의 원본 높이
*/
fun setAspectRatio(width: Int, height: Int) {
require(width > 0 && height > 0) { "Size cannot be negative." }
aspectRatio = width.toFloat() / height.toFloat()
// 레이아웃을 다시 계산하도록 시스템에 요청합니다.
requestLayout()
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val viewWidth = MeasureSpec.getSize(widthMeasureSpec)
val viewHeight = MeasureSpec.getSize(heightMeasureSpec)
if (aspectRatio == 0f) {
// 화면 비율이 설정되지 않았다면, 측정된 크기를 그대로 사용합니다.
setMeasuredDimension(viewWidth, viewHeight)
} else {
// 화면 비율이 설정되었다면, '센터 크롭' 방식으로 크기를 재계산합니다.
val newWidth: Int
val newHeight: Int
// 현재 뷰의 방향에 따라 실제 비율을 조정합니다.
val actualRatio = if (viewWidth > viewHeight) aspectRatio else 1f / aspectRatio
if (viewWidth < viewHeight * actualRatio) {
newWidth = (viewHeight * actualRatio).roundToInt()
newHeight = viewHeight
} else {
newWidth = viewWidth
newHeight = (viewWidth / actualRatio).roundToInt()
}
// 최종적으로 계산된 크기를 뷰의 크기로 설정합니다.
setMeasuredDimension(newWidth, newHeight)
}
}
}SurfaceView 를 Customize 해서 AutoFitSurfaceView 클래스를 만들었다. 아니, 사실 가져왔다. 구글의 Camera 관련 Sample (Git) 에서 대부분 가져왔다. Android 의 새로운 기능을 (Jetpack Compose, Jetpack Navigation 등) 을 배워보려면 우선은 Google Sample Git Repository 를 확인하는 습관을 들여야 한다. 각설하고, 화면 사용자에게 보여지는 화면의 찌그러짐 현상을 해결하기 위해 AutoFitSurfaceView 를 사용하는 것이다.
구체적으로 만약에 Camera2 API 의 getOutputSize 메소드를 통해 목록을 받아와서 1920 x 1080 이 선택되었다고 가정해보자. AutoFitSurfaceView 는 전달 받은 비율을 수동적으로 따르면서 화면이 이상하게 보이지 않게(찌그러지지 않게) 처리해준다.
Raw 촬영의 화룡점정은 DNG(DngCreator) 이다. DNG(Digital Negative) 는 어도비(Adobe) 사가 만든 Raw 이미지 파일 형식의 표준 규격이다. 이름 그대로 '디지털 필름 원판' 이라고 생각하면 쉽다. 기존의 JPEG 가 완성된 요리라고 하면, RAW 는 신선한 밀키트라고 할 수 있다. DNG 는 이러한 신선한 밀키트를 담은 포장 규격같은 것이다.
카메라 제조사마다 자신들만의 '밀키트 포장 방식' (SONY 는 .ARW, 캐논은 .CR3) 이 제각각이라는 점이다. 이 때문에 A사(임의의 회사) 카메라 Raw 파일이 열리지 않는 문제가 있었다고 한다. 이 때 Adobe 가 나서서 "앞으로 우리 모두 이 포장 규격으로 통일하겠다" 라고 제안한 게 DNG 라고 한다.
1. 높은 호환성: DNG는 공개된 표준이므로, 포토샵, 라이트룸은 물론 안드로이드, iOS 시스템 자체에서도 DNG 파일을 안정적으로 읽고 해석가능.
2. 모든 정보 포함: 단순히 픽셀 데이터만 담는 것이 아니라, 어떤 카메라로, 어떤 렌즈와 어떤 설정값으로 찍었는지에 대한 '레시피 노트(메타데이터)' 까지 파일 안에 함께 저장.
