Skip to content

Commit 6fffe0e

Browse files
committed
[BOOK-275] feat: Cloud Vision API를 사용한 문장 스캔 기능 구현
1 parent 98cf936 commit 6fffe0e

File tree

3 files changed

+76
-47
lines changed

3 files changed

+76
-47
lines changed

feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrPresenter.kt

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
package com.ninecraft.booket.feature.record.ocr
22

3+
import android.util.Base64
34
import androidx.compose.runtime.Composable
4-
import androidx.compose.runtime.DisposableEffect
55
import androidx.compose.runtime.getValue
66
import androidx.compose.runtime.mutableStateOf
7+
import androidx.compose.runtime.rememberCoroutineScope
78
import androidx.compose.runtime.setValue
8-
import com.ninecraft.booket.core.ocr.analyzer.LiveTextAnalyzer
9+
import com.ninecraft.booket.core.ocr.analyzer.CloudOcrRecognizer
910
import com.ninecraft.booket.feature.screens.OcrScreen
11+
import com.orhanobut.logger.Logger
1012
import com.slack.circuit.codegen.annotations.CircuitInject
1113
import com.slack.circuit.retained.rememberRetained
1214
import com.slack.circuit.runtime.Navigator
@@ -17,14 +19,16 @@ import dagger.assisted.AssistedInject
1719
import dagger.hilt.android.components.ActivityRetainedComponent
1820
import kotlinx.collections.immutable.persistentListOf
1921
import kotlinx.collections.immutable.toPersistentList
22+
import kotlinx.coroutines.launch
2023

2124
class OcrPresenter @AssistedInject constructor(
2225
@Assisted private val navigator: Navigator,
23-
private val liveTextAnalyzer: LiveTextAnalyzer.Factory,
26+
private val recognizer: CloudOcrRecognizer,
2427
) : Presenter<OcrUiState> {
2528

2629
@Composable
2730
override fun present(): OcrUiState {
31+
val scope = rememberCoroutineScope()
2832
var currentUi by rememberRetained { mutableStateOf(OcrUi.CAMERA) }
2933
var isPermissionDialogVisible by rememberRetained { mutableStateOf(false) }
3034
var sentenceList by rememberRetained { mutableStateOf(emptyList<String>().toPersistentList()) }
@@ -33,18 +37,37 @@ class OcrPresenter @AssistedInject constructor(
3337
var mergedSentence by rememberRetained { mutableStateOf("") }
3438
var isTextDetectionFailed by rememberRetained { mutableStateOf(false) }
3539
var isRecaptureDialogVisible by rememberRetained { mutableStateOf(false) }
36-
37-
val analyzer = rememberRetained {
38-
liveTextAnalyzer.create(
39-
onTextDetected = { text ->
40-
recognizedText = text
41-
},
42-
)
43-
}
44-
45-
DisposableEffect(Unit) {
46-
onDispose {
47-
analyzer.cancel()
40+
var isLoading by rememberRetained { mutableStateOf(false) }
41+
42+
fun recognizeText(base64Image: String) {
43+
scope.launch {
44+
try {
45+
isLoading = true
46+
recognizer.recognizeText(base64Image)
47+
.onSuccess {
48+
val text = it.responses.firstOrNull()?.fullTextAnnotation?.text.orEmpty()
49+
recognizedText = text
50+
51+
if (text.isNotBlank()) {
52+
isTextDetectionFailed = false
53+
val sentences = text
54+
.split("\n")
55+
.map { it.trim() }
56+
.filter { it.isNotEmpty() }
57+
58+
sentenceList = persistentListOf(*sentences.toTypedArray())
59+
currentUi = OcrUi.RESULT
60+
} else {
61+
isTextDetectionFailed = true
62+
}
63+
}
64+
.onFailure {
65+
isTextDetectionFailed = true
66+
Logger.e("Cloud Vision API Error: ${it.message}")
67+
}
68+
} finally {
69+
isLoading = false
70+
}
4871
}
4972
}
5073

@@ -62,24 +85,9 @@ class OcrPresenter @AssistedInject constructor(
6285
isPermissionDialogVisible = false
6386
}
6487

65-
is OcrUiEvent.OnFrameReceived -> {
66-
analyzer.analyze(event.imageProxy)
67-
}
68-
6988
is OcrUiEvent.OnCaptureButtonClick -> {
70-
if (recognizedText.isEmpty()) {
71-
isTextDetectionFailed = true
72-
} else {
73-
isTextDetectionFailed = false
74-
75-
val sentences = recognizedText
76-
.split("\n")
77-
.map { it.trim() }
78-
.filter { it.isNotEmpty() }
79-
sentenceList = persistentListOf(*sentences.toTypedArray())
80-
81-
currentUi = OcrUi.RESULT
82-
}
89+
val base64Image = Base64.encodeToString(event.imageData, Base64.NO_WRAP)
90+
recognizeText(base64Image)
8391
}
8492

8593
is OcrUiEvent.OnReCaptureButtonClick -> {
@@ -119,6 +127,7 @@ class OcrPresenter @AssistedInject constructor(
119127
selectedIndices = selectedIndices,
120128
isTextDetectionFailed = isTextDetectionFailed,
121129
isRecaptureDialogVisible = isRecaptureDialogVisible,
130+
isLoading = isLoading,
122131
eventSink = ::handleEvent,
123132
)
124133
}

feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUi.kt

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import android.view.ViewGroup
88
import android.widget.LinearLayout
99
import androidx.activity.compose.rememberLauncherForActivityResult
1010
import androidx.activity.result.contract.ActivityResultContracts
11-
import androidx.camera.core.ImageAnalysis
11+
import androidx.camera.core.ImageCapture
12+
import androidx.camera.core.ImageCaptureException
1213
import androidx.camera.view.LifecycleCameraController
1314
import androidx.camera.view.PreviewView
1415
import androidx.compose.foundation.background
@@ -29,6 +30,7 @@ import androidx.compose.foundation.lazy.LazyColumn
2930
import androidx.compose.foundation.shape.CircleShape
3031
import androidx.compose.material3.Button
3132
import androidx.compose.material3.ButtonDefaults
33+
import androidx.compose.material3.CircularProgressIndicator
3234
import androidx.compose.material3.Icon
3335
import androidx.compose.material3.Text
3436
import androidx.compose.runtime.Composable
@@ -66,9 +68,11 @@ import com.ninecraft.booket.feature.record.R
6668
import com.ninecraft.booket.feature.record.ocr.component.CameraFrame
6769
import com.ninecraft.booket.feature.record.ocr.component.SentenceBox
6870
import com.ninecraft.booket.feature.screens.OcrScreen
71+
import com.orhanobut.logger.Logger
6972
import com.slack.circuit.codegen.annotations.CircuitInject
7073
import dagger.hilt.android.components.ActivityRetainedComponent
7174
import tech.thdev.compose.exteions.system.ui.controller.rememberSystemUiController
75+
import java.io.File
7276
import com.ninecraft.booket.core.designsystem.R as designR
7377

7478
@CircuitInject(OcrScreen::class, ActivityRetainedComponent::class)
@@ -138,27 +142,17 @@ private fun CameraPreview(
138142
}
139143

140144
/**
141-
* Camera Controller & ImageAnalyzer
145+
* Camera Controller
142146
*/
143147
val cameraController = remember { LifecycleCameraController(context) }
144-
val imageAnalyzer = remember {
145-
ImageAnalysis.Analyzer { imageProxy ->
146-
state.eventSink(OcrUiEvent.OnFrameReceived(imageProxy))
147-
}
148-
}
149148

150149
DisposableEffect(isGranted, lifecycleOwner, cameraController) {
151150
if (isGranted) {
152151
cameraController.bindToLifecycle(lifecycleOwner)
153-
cameraController.setImageAnalysisAnalyzer(
154-
ContextCompat.getMainExecutor(context),
155-
imageAnalyzer,
156-
)
157152
}
158153

159154
onDispose {
160155
cameraController.unbind()
161-
cameraController.clearImageAnalysisAnalyzer()
162156
}
163157
}
164158

@@ -254,7 +248,24 @@ private fun CameraPreview(
254248

255249
Button(
256250
onClick = {
257-
state.eventSink(OcrUiEvent.OnCaptureButtonClick)
251+
val executor = ContextCompat.getMainExecutor(context)
252+
val photoFile = File.createTempFile("ocr_", ".jpg", context.cacheDir)
253+
val output = ImageCapture.OutputFileOptions.Builder(photoFile).build()
254+
255+
cameraController.takePicture(
256+
output,
257+
executor,
258+
object : ImageCapture.OnImageSavedCallback {
259+
override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
260+
val bytes = photoFile.readBytes()
261+
state.eventSink(OcrUiEvent.OnCaptureButtonClick(bytes))
262+
}
263+
264+
override fun onError(exception: ImageCaptureException) {
265+
Logger.e("ImageCaptureException: ${exception.message}")
266+
}
267+
}
268+
)
258269
},
259270
modifier = Modifier.size(72.dp),
260271
shape = CircleShape,
@@ -272,6 +283,15 @@ private fun CameraPreview(
272283
}
273284
Spacer(modifier = Modifier.height(ReedTheme.spacing.spacing4))
274285
}
286+
287+
if (state.isLoading) {
288+
Box(
289+
modifier = Modifier.fillMaxSize(),
290+
contentAlignment = Alignment.Center,
291+
) {
292+
CircularProgressIndicator(color = ReedTheme.colors.contentBrand)
293+
}
294+
}
275295
}
276296
}
277297

feature/record/src/main/kotlin/com/ninecraft/booket/feature/record/ocr/OcrUiState.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,15 @@ data class OcrUiState(
1313
val selectedIndices: Set<Int> = emptySet(),
1414
val isTextDetectionFailed: Boolean = false,
1515
val isRecaptureDialogVisible: Boolean = false,
16+
val isLoading: Boolean = false,
1617
val eventSink: (OcrUiEvent) -> Unit,
1718
) : CircuitUiState
1819

1920
sealed interface OcrUiEvent : CircuitUiEvent {
2021
data object OnCloseClick : OcrUiEvent
2122
data object OnShowPermissionDialog : OcrUiEvent
2223
data object OnHidePermissionDialog : OcrUiEvent
23-
data class OnFrameReceived(val imageProxy: ImageProxy) : OcrUiEvent
24-
data object OnCaptureButtonClick : OcrUiEvent
24+
data class OnCaptureButtonClick(val imageData: ByteArray) : OcrUiEvent
2525
data object OnReCaptureButtonClick : OcrUiEvent
2626
data object OnSelectionConfirmed : OcrUiEvent
2727
data object OnRecaptureDialogConfirmed : OcrUiEvent

0 commit comments

Comments
 (0)