From 43fea5cd4bdb26526040082b9626f28f9c463b9f Mon Sep 17 00:00:00 2001 From: easyhooon Date: Tue, 3 Feb 2026 23:46:17 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[BOOK-493]=20refactor:=20CameraX=20AndroidV?= =?UTF-8?q?iew=20+=20PreviewView=20->=20Composable=20CameraXViewfinder?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- feature/record/build.gradle.kts | 2 +- .../record/ocr/content/OcrCameraContent.kt | 63 ++++++++++--------- gradle/libs.versions.toml | 2 +- 3 files changed, 36 insertions(+), 31 deletions(-) diff --git a/feature/record/build.gradle.kts b/feature/record/build.gradle.kts index b3056c2c..71788033 100644 --- a/feature/record/build.gradle.kts +++ b/feature/record/build.gradle.kts @@ -19,7 +19,7 @@ dependencies { libs.androidx.activity.compose, libs.androidx.camera.camera2, libs.androidx.camera.lifecycle, - libs.androidx.camera.view, + libs.androidx.camera.compose, libs.compose.keyboard.state, libs.logger, diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt index e1daf915..b17b6c25 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt @@ -4,15 +4,17 @@ import android.content.Intent import android.content.pm.PackageManager import android.net.Uri import android.provider.Settings -import android.view.ViewGroup -import android.widget.LinearLayout import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.compose.CameraXViewfinder +import androidx.camera.core.CameraSelector import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException -import androidx.camera.view.LifecycleCameraController -import androidx.camera.view.PreviewView +import androidx.camera.core.Preview +import androidx.camera.core.SurfaceRequest +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.lifecycle.awaitInstance import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -28,8 +30,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -38,7 +42,6 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.lifecycle.Lifecycle @@ -115,17 +118,28 @@ internal fun OcrCameraContent( } /** - * Camera Controller + * Camera Setup (ProcessCameraProvider + Preview + ImageCapture) */ - val cameraController = remember { LifecycleCameraController(context) } - - DisposableEffect(isGranted, lifecycleOwner, cameraController) { - if (isGranted) { - cameraController.bindToLifecycle(lifecycleOwner) + var surfaceRequest by remember { mutableStateOf(null) } + val preview = remember { + Preview.Builder().build().also { + it.setSurfaceProvider { request -> + surfaceRequest = request + } } + } + val imageCapture = remember { ImageCapture.Builder().build() } - onDispose { - cameraController.unbind() + LaunchedEffect(isGranted) { + if (!isGranted) return@LaunchedEffect + ProcessCameraProvider.awaitInstance(context).apply { + unbindAll() + bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + imageCapture, + ) } } @@ -203,21 +217,12 @@ internal fun OcrCameraContent( .height(200.dp) .align(Alignment.Center), ) { - AndroidView( - modifier = Modifier.fillMaxSize(), - factory = { context -> - PreviewView(context).apply { - layoutParams = LinearLayout.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT, - ) - clipToOutline = true - implementationMode = PreviewView.ImplementationMode.COMPATIBLE - scaleType = PreviewView.ScaleType.FILL_CENTER - controller = cameraController - } - }, - ) + surfaceRequest?.let { request -> + CameraXViewfinder( + surfaceRequest = request, + modifier = Modifier.fillMaxSize(), + ) + } } CameraFrame(modifier = Modifier.align(Alignment.Center)) } @@ -248,7 +253,7 @@ internal fun OcrCameraContent( val photoFile = File.createTempFile("ocr_", ".jpg", context.cacheDir) val output = ImageCapture.OutputFileOptions.Builder(photoFile).build() - cameraController.takePicture( + imageCapture.takePicture( output, executor, object : ImageCapture.OnImageSavedCallback { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab0bb25e..54e92f30 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -95,7 +95,7 @@ androidx-datastore-preferences = { group = "androidx.datastore", name = "datasto androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "androidx-camera" } androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "androidx-camera" } androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "androidx-camera" } -androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "androidx-camera" } +androidx-camera-compose = { group = "androidx.camera", name = "camera-compose", version.ref = "androidx-camera" } androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "androidx-compose-bom" } androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" } From 662deb69ce2d75f00610d28d20b1a39a758cd57f Mon Sep 17 00:00:00 2001 From: easyhooon Date: Thu, 26 Feb 2026 21:16:20 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[BOOK-493]=20fix:=20OCR=20=EC=BA=A1?= =?UTF-8?q?=EC=B2=98=20=EC=8B=9C=20Preview=20=EC=98=81=EC=97=AD=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20crop=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 센서 전체 해상도로 OCR 분석하던 문제를 수정하여, CameraXViewfinder에 표시되는 높이 200dp Preview 영역만 crop 후 분석하도록 변경 --- .../record/ocr/content/OcrCameraContent.kt | 50 ++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt index b17b6c25..fe52bde8 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt @@ -2,6 +2,7 @@ package com.ninecraft.booket.feature.record.ocr.content import android.content.Intent import android.content.pm.PackageManager +import android.graphics.BitmapFactory import android.net.Uri import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult @@ -36,7 +37,9 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -129,6 +132,9 @@ internal fun OcrCameraContent( } } val imageCapture = remember { ImageCapture.Builder().build() } + val density = LocalDensity.current + val screenWidthPx = with(density) { LocalConfiguration.current.screenWidthDp.dp.roundToPx() } + val previewHeightPx = with(density) { 200.dp.roundToPx() } LaunchedEffect(isGranted) { if (!isGranted) return@LaunchedEffect @@ -258,7 +264,8 @@ internal fun OcrCameraContent( executor, object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { - state.eventSink(OcrUiEvent.OnImageCaptured(photoFile.toUri())) + val croppedFile = cropToPreviewArea(photoFile, screenWidthPx, previewHeightPx) + state.eventSink(OcrUiEvent.OnImageCaptured(croppedFile.toUri())) } override fun onError(exception: ImageCaptureException) { @@ -314,6 +321,47 @@ internal fun OcrCameraContent( } } +/** + * 캡처된 전체 이미지를 Preview 영역(화면 중앙, fillMaxWidth x 200dp) 기준으로 center crop합니다. + * + * CameraXViewfinder는 center-crop(FILL) 방식으로 렌더링하므로, + * 캡처 이미지에서 실제 화면에 보이는 영역만 잘라내어 OCR 분석 범위를 제한합니다. + * + * center-crop scale = max(viewWidth / imgWidth, viewHeight / imgHeight) + * 이 scale로 나눈 viewport 크기가 원본 이미지에서의 crop 영역입니다. + */ +private fun cropToPreviewArea(photoFile: File, screenWidthPx: Int, previewHeightPx: Int): File { + val original = BitmapFactory.decodeFile(photoFile.absolutePath) ?: return photoFile + + val imgW = original.width + val imgH = original.height + + // center-crop fill: 이미지가 Preview 영역을 완전히 채우도록 스케일링 + val scale = maxOf( + screenWidthPx.toFloat() / imgW, + previewHeightPx.toFloat() / imgH, + ) + + // 원본 이미지에서 실제 보이는 영역 크기 + val cropW = (screenWidthPx / scale).toInt().coerceAtMost(imgW) + val cropH = (previewHeightPx / scale).toInt().coerceAtMost(imgH) + + // 중앙 정렬 + val cropX = (imgW - cropW) / 2 + val cropY = (imgH - cropH) / 2 + + val cropped = android.graphics.Bitmap.createBitmap(original, cropX, cropY, cropW, cropH) + + photoFile.outputStream().use { out -> + cropped.compress(android.graphics.Bitmap.CompressFormat.JPEG, 90, out) + } + + if (cropped !== original) cropped.recycle() + original.recycle() + + return photoFile +} + @ComponentPreview @Composable private fun OcrCameraContentPreview() { From 669b65a060378d143e39963d93b818032c71fffd Mon Sep 17 00:00:00 2001 From: easyhooon Date: Thu, 26 Feb 2026 23:31:56 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[BOOK-493]=20fix:=20ViewPort=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=EC=9C=BC=EB=A1=9C=20OCR=20=EC=BA=A1=EC=B2=98=20?= =?UTF-8?q?=EC=98=81=EC=97=AD=EC=9D=84=20Preview=20=EB=86=92=EC=9D=B4=2020?= =?UTF-8?q?0dp=EB=A1=9C=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CameraX 마이그레이션 시 LifecycleCameraController의 자동 ViewPort 설정이 누락되어 센서 전체 해상도로 OCR이 수행되던 문제를 수정 --- .../record/ocr/content/OcrCameraContent.kt | 65 ++++++------------- 1 file changed, 19 insertions(+), 46 deletions(-) diff --git a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt index fe52bde8..f4c2e422 100644 --- a/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt +++ b/feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/content/OcrCameraContent.kt @@ -2,8 +2,8 @@ package com.ninecraft.booket.feature.record.ocr.content import android.content.Intent import android.content.pm.PackageManager -import android.graphics.BitmapFactory import android.net.Uri +import android.util.Rational import android.provider.Settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest @@ -14,6 +14,8 @@ import androidx.camera.core.ImageCapture import androidx.camera.core.ImageCaptureException import androidx.camera.core.Preview import androidx.camera.core.SurfaceRequest +import androidx.camera.core.UseCaseGroup +import androidx.camera.core.ViewPort import androidx.camera.lifecycle.ProcessCameraProvider import androidx.camera.lifecycle.awaitInstance import androidx.compose.foundation.background @@ -140,11 +142,24 @@ internal fun OcrCameraContent( if (!isGranted) return@LaunchedEffect ProcessCameraProvider.awaitInstance(context).apply { unbindAll() + + // Preview 영역(fillMaxWidth x 200dp) 비율로 ViewPort를 설정하여 + // ImageCapture 출력을 해당 영역으로 제한 + val viewPort = ViewPort.Builder( + Rational(screenWidthPx, previewHeightPx), + preview.targetRotation, + ).build() + + val useCaseGroup = UseCaseGroup.Builder() + .setViewPort(viewPort) + .addUseCase(preview) + .addUseCase(imageCapture) + .build() + bindToLifecycle( lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, - preview, - imageCapture, + useCaseGroup, ) } } @@ -264,8 +279,7 @@ internal fun OcrCameraContent( executor, object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) { - val croppedFile = cropToPreviewArea(photoFile, screenWidthPx, previewHeightPx) - state.eventSink(OcrUiEvent.OnImageCaptured(croppedFile.toUri())) + state.eventSink(OcrUiEvent.OnImageCaptured(photoFile.toUri())) } override fun onError(exception: ImageCaptureException) { @@ -321,47 +335,6 @@ internal fun OcrCameraContent( } } -/** - * 캡처된 전체 이미지를 Preview 영역(화면 중앙, fillMaxWidth x 200dp) 기준으로 center crop합니다. - * - * CameraXViewfinder는 center-crop(FILL) 방식으로 렌더링하므로, - * 캡처 이미지에서 실제 화면에 보이는 영역만 잘라내어 OCR 분석 범위를 제한합니다. - * - * center-crop scale = max(viewWidth / imgWidth, viewHeight / imgHeight) - * 이 scale로 나눈 viewport 크기가 원본 이미지에서의 crop 영역입니다. - */ -private fun cropToPreviewArea(photoFile: File, screenWidthPx: Int, previewHeightPx: Int): File { - val original = BitmapFactory.decodeFile(photoFile.absolutePath) ?: return photoFile - - val imgW = original.width - val imgH = original.height - - // center-crop fill: 이미지가 Preview 영역을 완전히 채우도록 스케일링 - val scale = maxOf( - screenWidthPx.toFloat() / imgW, - previewHeightPx.toFloat() / imgH, - ) - - // 원본 이미지에서 실제 보이는 영역 크기 - val cropW = (screenWidthPx / scale).toInt().coerceAtMost(imgW) - val cropH = (previewHeightPx / scale).toInt().coerceAtMost(imgH) - - // 중앙 정렬 - val cropX = (imgW - cropW) / 2 - val cropY = (imgH - cropH) / 2 - - val cropped = android.graphics.Bitmap.createBitmap(original, cropX, cropY, cropW, cropH) - - photoFile.outputStream().use { out -> - cropped.compress(android.graphics.Bitmap.CompressFormat.JPEG, 90, out) - } - - if (cropped !== original) cropped.recycle() - original.recycle() - - return photoFile -} - @ComponentPreview @Composable private fun OcrCameraContentPreview() {