티스토리 뷰
개요
Camera API를 사용하면서 화면의 필터를 구현하고 있다. 이 때 카메라 프레임을 가공하는 방식은 크게 두가지가 있다.
- CPU 기반 YUV Buffer 직접 조작
- GPU 기반 OpenGL 처리
이 둘은 단순 구현 방식의 차이가 아니라 메모리 위치, 연산 주체, 데이터 이동 비용, 지연시간 구조가 완전히 다르다.
YUV Buffer 조작
Camera2/CameraX의 ImageAnalysis에서 기본적으로 받는 포멧은 YUV_420_888이다.
이 구조는 3개의 Plane을 갖는다.
- Y Plane (밝기)
- U Plane (색차)
- V Plane (색차)
Y는 해상도 그대로, U,V는 1/2 또는 1/4 크기이다.
데이터 흐름
- Camera Sensor
- ISP로 Camera Sensor에서 들어오는 원시 데이터를 YUV/RGB로 변환
- HAL이 ISP의 변환 결과를 GraphicBuffer에 기록
- ImageReader가 Surface에서 Buffer 획득
- CPU가 YUV ByteBuffer에 접근
- CPU 연산
- Bitmap 생성 (RAM)
- Bitmap을 GPU Texture로 업로드
- 화면 출력
카메라 프레임은 ISP를 거쳐 시스템 메모리에 저장되며, CPU는 이 메모리에 직접 접근해 YUV 버퍼를 처리한다. 이후 CPU에서 생성한 Bitmap은 화면에 표시하기 위해 GPU 텍스처로 업로드 된다.
이 RAM → GPU 업로드 과정이 매 프레임 발생하며, 여기서 메모리 전송 비용이 발생한다.
장점
- 구현이 비교적 단순하다.
- ML/버전 라이브러리와 직접 연결이 가능하다.
단점
- 해상도가 증가하는 만큼 연산이 증가하고 CPU는 병렬 연산이 아니기 때문에 고해상도에서 CPU 부하가 크다.
- 프레임 연산 시간이 증가하면 새 프레임이 도착해도 이전 프레임은 처리 중인 상태가 될 수 있다. 이 때 연산중인 이전 프레임을 버리기 때문에 프레임 드랍 가능성이 있다.
OpenGL
데이터 흐름
- Camera Sensor
- ISP로 Camera Sensor에서 들어오는 원시 데이터를 YUV/RGB로 변환
- HAL이 YUV를 받아 GraphicBuffer에 기록
- SurfaceTexture가 최신 GraphicBuffer를 가져온 후 OpenGL Texture로 연결
- OpenGL Texture를 통해 GPU 접근을 가능하도록 핸들 제공
- Fragment Shader를 통해 GPU 연산을 수행
- 화면 출력
프레임이 GPU 메모리에서 GPU 연산으로 바로 처리된다. 따라서 CPU는 거의 개입하지 않는다.
장점
- 고해상도 필터를 실시간 처리하기 좋다.
- GPU는 각 픽셀을 독립적으로 처리할 수 있는 연산을 동시에 처리 가능하다. 따라서 CPU 보다 연산 속도가 빠르며, 프레임 처리 지연을 최소화할 수 있다.
단점
- GPU용 코드는 CPU 코드와 달리 파이프 라인과 메모리 관리가 필요하기 때문에 구현이 복잡하다.
- 행렬 연산이 필요하기 때문에 좌표계/회전/Matrix 처리 난이도가 높다.
YUV Buffer 직접 구현해보기
YUV Buffer를 직접 조작하여 카메라 흑백 필터를 구현했다.
흑백 필터 구현을 위해 YUV 데이터를 RGB로 변환하고, 각 좌표에 Bitmap으로 적용하였다.
문제상황
기존에는 함수 호출 시마다 Bitmap을 생성하고, 중첩 for문 연산을 메인 스레드에서 수행했다.
이로 인해 메인 스레드가 화면 갱신을 제때 처리하지 못해 FPS가 극도로 낮아지는 문제가 발생했다.
class YUVRenderer(
private val imageView: ImageView,
) : Renderer<YUVFrame> {
override fun render(frame: YUVFrame) {
val width = frame.width
val height = frame.height
val bitmap = createBitmap(width, height)
val y = frame.yPlane
val u = frame.uPlane
val v = frame.vPlane
for (j in 0 until height) {
for (i in 0 until width) {
val yIndex = j * width + i
val uvIndex = (j / 2) * (width / 2) + (i / 2)
val yValue = y.get(yIndex).toInt() and 0xFF
val uValue = (u.get(uvIndex).toInt() and 0xFF) - 128
val vValue = (v.get(uvIndex).toInt() and 0xFF) - 128
var r = (yValue + 1.402 * vValue).toInt()
var g = (yValue - 0.344136 * uValue - 0.714136 * vValue).toInt()
var b = (yValue + 1.772 * uValue).toInt()
r = r.coerceIn(0, 255)
g = g.coerceIn(0, 255)
b = b.coerceIn(0, 255)
bitmap[i, j] = Color.rgb(r, g, b)
}
}
imageView.post { imageView.setImageBitmap(bitmap) }
}
}

해결
- 변환 연산은 백그라운드 코루틴에서 수행하고, 메인 스레드는 변환된 Bitmap 반영만 처리하도록 구조를 개선하였다.
- Bitmap을 매 프레임마다 새로 생성하지 않고 재사용하도록 개선하였다.
- SharedFlow의 collectLatest를 통해 이전 프레임을 처리 중에 새 프레임이 들어오면 ensureActive()를 통해 처리를 중단하고 새프레임을 연산하도록 했다.
- 추가로 현재 YUVRenderer객체는 ImageView를 직접 참조하고 있다. 이는 Activity나 fragment가 종료되어도 참조가 남아 메모리 누수가 발생할 가능성이 있다. 따라서 WeakReference를 사용해 GC가 발생했을 때 반드시 회수하도록 했다.
class YUVRenderer(
imageView: ImageView,
) : Renderer<YUVFrame> {
private val imageViewRef = WeakReference(imageView)
private val scope = CoroutineScope(Job() + Dispatchers.Default)
private val frameFlow = MutableSharedFlow<YUVFrame>(extraBufferCapacity = 1)
private var bitmap: Bitmap? = null
private var pixels: IntArray? = null
init {
startWorker()
}
override fun render(frame: YUVFrame) {
frameFlow.tryEmit(frame)
}
fun release() {
scope.cancel()
}
private fun startWorker() {
scope.launch {
frameFlow.collectLatest {
processFrame(it)
}
}
}
private suspend fun processFrame(frame: YUVFrame) {
val width = frame.width
val height = frame.height
val bmp =
bitmap?.takeIf { it.width == width && it.height == height }
?: createBitmap(width, height).also { bitmap = it }
val buffer =
pixels?.takeIf { it.size == width * height }
?: IntArray(width * height).also { pixels = it }
convertYUVToRGB(width, height, frame, buffer)
bmp.setPixels(buffer, 0, width, 0, 0, width, height)
withContext(Dispatchers.Main) {
imageViewRef.get()?.setImageBitmap(bmp)
}
}
private suspend fun convertYUVToRGB(
width: Int,
height: Int,
frame: YUVFrame,
pixels: IntArray,
) {
val y = frame.yPlane
val u = frame.uPlane
val v = frame.vPlane
for (j in 0 until height) {
currentCoroutineContext().ensureActive()
val uvRow = (j shr 1) * (width shr 1)
val yRow = j * width
for (i in 0 until width) {
val yIndex = yRow + i
val uvIndex = uvRow + (i shr 1)
val yValue = y[yIndex].toInt() and 0xFF
val uValue = (u[uvIndex].toInt() and 0xFF) - 128
val vValue = (v[uvIndex].toInt() and 0xFF) - 128
var r = (yValue + 1.402f * vValue).toInt()
var g = (yValue - 0.344136f * uValue - 0.714136f * vValue).toInt()
var b = (yValue + 1.772f * uValue).toInt()
r = r.coerceIn(0, 255)
g = g.coerceIn(0, 255)
b = b.coerceIn(0, 255)
pixels[yIndex] = Color.rgb(r, g, b)
}
}
}
}

한계 및 해결 방안
우선 위와 같이 버퍼 재사용, 이전 프레임 자동 스킵, 최신 프레임 처리 방식으로 메모리 부하와 병목 현상을 어느 정도 개선했다. 하지만 이 방법은 완전히 해결하기에는 한계가 있다.
픽셀 단위 연산을 수행하기 때문에 해상도가 높아질수록 CPU 연산량이 급격히 증가한다. 그 결과 FPS가 떨어지거나, 코루틴이 끝나기 전에 새로운 프레임이 들어와 이전 프레임이 취소되는 문제가 발생할 수 있다.
따라서 CPU 기반 처리보다는 OpenGL, RenderScript 등 병렬 연산이 더 수월한 GPU를 활용하는 것이 이러한 문제를 해결할 수 있는 효과적인 방법이다.
'안드로이드' 카테고리의 다른 글
| 다양한 화면 크기에 대응하기 (0) | 2026.06.02 |
|---|---|
| FragmentTransaction과 State Loss (0) | 2026.05.26 |
| CameraX VS Camera2 (0) | 2026.02.22 |
| Coil (0) | 2026.01.04 |
| XML VS Jetpack Compose (0) | 2026.01.01 |
