-
Notifications
You must be signed in to change notification settings - Fork 0
feat: OCR 인식률 개선을 위해 Cloud Vision API로 마이그레이션 #155
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
30ab43e
8e362f6
98cf936
6fffe0e
9434d5b
793189b
4b798d7
fbf8b14
14d6c15
f772c15
e53d4bf
2ed9202
7b3b81b
89997a3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,19 +1,37 @@ | ||
| @file:Suppress("INLINE_FROM_HIGHER_PLATFORM") | ||
|
|
||
| import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties | ||
|
|
||
|
|
||
| plugins { | ||
| alias(libs.plugins.booket.android.library) | ||
| alias(libs.plugins.booket.android.retrofit) | ||
| alias(libs.plugins.booket.android.hilt) | ||
| } | ||
|
|
||
| android { | ||
| namespace = "com.ninecraft.booket.core.ocr" | ||
|
|
||
| defaultConfig { | ||
| buildConfigField("String", "CLOUD_VISION_API_KEY", getApiKey("CLOUD_VISION_API_KEY")) | ||
| } | ||
|
|
||
| buildFeatures { | ||
| buildConfig = true | ||
| } | ||
| } | ||
|
|
||
| dependencies { | ||
| implementations( | ||
| projects.core.common, | ||
|
|
||
| libs.logger, | ||
| libs.androidx.camera.core, | ||
|
|
||
| libs.google.mlkit.text.recognition.korean, | ||
| ) | ||
| } | ||
|
|
||
| fun getApiKey(propertyKey: String): String { | ||
| return gradleLocalProperties(rootDir, providers).getProperty(propertyKey) | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,45 @@ | ||||||||||||||||||||||||||||||
| package com.ninecraft.booket.core.ocr.analyzer | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| import android.net.Uri | ||||||||||||||||||||||||||||||
| import android.util.Base64 | ||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.common.utils.runSuspendCatching | ||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.ocr.BuildConfig | ||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.ocr.model.AnnotateImageRequest | ||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.ocr.model.CloudVisionRequest | ||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.ocr.model.CloudVisionResponse | ||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.ocr.model.Feature | ||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.ocr.model.ImageContext | ||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.ocr.model.VisionImage | ||||||||||||||||||||||||||||||
| import com.ninecraft.booket.core.ocr.service.CloudVisionService | ||||||||||||||||||||||||||||||
| import kotlinx.coroutines.Dispatchers | ||||||||||||||||||||||||||||||
| import kotlinx.coroutines.withContext | ||||||||||||||||||||||||||||||
| import java.io.File | ||||||||||||||||||||||||||||||
| import javax.inject.Inject | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| class CloudOcrRecognizer @Inject constructor( | ||||||||||||||||||||||||||||||
| private val service: CloudVisionService, | ||||||||||||||||||||||||||||||
| ) { | ||||||||||||||||||||||||||||||
| suspend fun recognizeText(imageUri: Uri): Result<CloudVisionResponse> = runSuspendCatching { | ||||||||||||||||||||||||||||||
| withContext(Dispatchers.IO) { | ||||||||||||||||||||||||||||||
| val filePath = imageUri.path ?: throw IllegalArgumentException("URI does not have a valid path.") | ||||||||||||||||||||||||||||||
| val file = File(filePath) | ||||||||||||||||||||||||||||||
| val byte = file.readBytes() | ||||||||||||||||||||||||||||||
| val base64Image = Base64.encodeToString(byte, Base64.NO_WRAP) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| val request = CloudVisionRequest( | ||||||||||||||||||||||||||||||
| requests = listOf( | ||||||||||||||||||||||||||||||
| AnnotateImageRequest( | ||||||||||||||||||||||||||||||
| image = VisionImage(base64Image), | ||||||||||||||||||||||||||||||
| features = listOf(Feature(type = "TEXT_DETECTION")), | ||||||||||||||||||||||||||||||
| imageContext = ImageContext(languageHints = null), | ||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||
|
Comment on lines
+33
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion 인식률 목적에 맞춘 기능 타입 전환: DOCUMENT_TEXT_DETECTION 권장 Cloud Vision에서 일반 텍스트가 아닌 문서 단위의 OCR 정밀도를 원하시면 - features = listOf(Feature(type = "TEXT_DETECTION")),
- imageContext = ImageContext(languageHints = null),
+ features = listOf(Feature(type = "DOCUMENT_TEXT_DETECTION")),
+ imageContext = ImageContext(languageHints = listOf("ko", "en")),추가로 언어 힌트(languageHints)를 사용 가능한 주 사용 언어(ko, en 등)로 제공하면 품질 향상에 도움이 됩니다. 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| ), | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| service.batchAnnotateImage( | ||||||||||||||||||||||||||||||
| apiKey = BuildConfig.CLOUD_VISION_API_KEY, | ||||||||||||||||||||||||||||||
| body = request, | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package com.ninecraft.booket.core.ocr.di | ||
|
|
||
| import com.ninecraft.booket.core.ocr.BuildConfig | ||
| import com.ninecraft.booket.core.ocr.service.CloudVisionService | ||
| import dagger.Module | ||
| import dagger.Provides | ||
| import dagger.hilt.InstallIn | ||
| import dagger.hilt.components.SingletonComponent | ||
| import kotlinx.serialization.json.Json | ||
| import okhttp3.MediaType.Companion.toMediaType | ||
| import okhttp3.OkHttpClient | ||
| import okhttp3.logging.HttpLoggingInterceptor | ||
| import retrofit2.Retrofit | ||
| import retrofit2.converter.kotlinx.serialization.asConverterFactory | ||
| import java.util.concurrent.TimeUnit | ||
| import javax.inject.Singleton | ||
|
|
||
| private const val BASE_URL = "https://vision.googleapis.com/" | ||
| private const val MaxTimeoutMillis = 15_000L | ||
|
|
||
| private val jsonRule = Json { | ||
| // 기본값도 JSON에 포함하여 직렬화 | ||
| encodeDefaults = true | ||
| // JSON에 정의되지 않은 키는 무시 (역직렬화 시 에러 방지) | ||
| ignoreUnknownKeys = true | ||
| // JSON을 보기 좋게 들여쓰기하여 포맷팅 | ||
| prettyPrint = true | ||
| // 엄격하지 않은 파싱 (따옴표 없는 키, 후행 쉼표 등 허용) | ||
| isLenient = true | ||
| } | ||
|
|
||
| private val jsonConverterFactory = jsonRule.asConverterFactory("application/json".toMediaType()) | ||
|
|
||
| @Module | ||
| @InstallIn(SingletonComponent::class) | ||
| object CloudVisionNetworkModule { | ||
|
|
||
| @Provides | ||
| @Singleton | ||
| @CloudVisionOkHttp | ||
| fun provideOkHttp(): OkHttpClient { | ||
| val log = HttpLoggingInterceptor().apply { | ||
| redactHeader("X-Goog-Api-Key") | ||
| level = if (BuildConfig.DEBUG) { | ||
| HttpLoggingInterceptor.Level.BASIC | ||
| } else { | ||
| HttpLoggingInterceptor.Level.NONE | ||
| } | ||
| } | ||
| return OkHttpClient.Builder() | ||
| .addInterceptor(log) | ||
| .connectTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) | ||
| .readTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) | ||
| .writeTimeout(MaxTimeoutMillis, TimeUnit.MILLISECONDS) | ||
| .build() | ||
| } | ||
|
|
||
| @Provides | ||
| @Singleton | ||
| @CloudVisionRetrofit | ||
| fun provideRetrofit( | ||
| @CloudVisionOkHttp okHttpClient: OkHttpClient, | ||
| ): Retrofit { | ||
| return Retrofit.Builder() | ||
| .baseUrl(BASE_URL) | ||
| .client(okHttpClient) | ||
| .addConverterFactory(jsonConverterFactory) | ||
| .build() | ||
| } | ||
|
|
||
| @Provides | ||
| @Singleton | ||
| fun provideVisionApi(@CloudVisionRetrofit retrofit: Retrofit): CloudVisionService = | ||
| retrofit.create(CloudVisionService::class.java) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.ninecraft.booket.core.ocr.di | ||
|
|
||
| import javax.inject.Qualifier | ||
|
|
||
| @Qualifier | ||
| @Retention(AnnotationRetention.BINARY) | ||
| annotation class CloudVisionOkHttp | ||
|
|
||
| @Qualifier | ||
| @Retention(AnnotationRetention.BINARY) | ||
| annotation class CloudVisionRetrofit |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package com.ninecraft.booket.core.ocr.model | ||
|
|
||
| import kotlinx.serialization.Serializable | ||
|
|
||
| @Serializable | ||
| data class CloudVisionRequest( | ||
| val requests: List<AnnotateImageRequest>, | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class AnnotateImageRequest( | ||
| val image: VisionImage, | ||
| val features: List<Feature>, | ||
| val imageContext: ImageContext? = null, | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class VisionImage( | ||
| val content: String, | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class Feature( | ||
| val type: String = "TEXT_DETECTION", | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class ImageContext( | ||
| val languageHints: List<String>? = null, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package com.ninecraft.booket.core.ocr.model | ||
|
|
||
| import kotlinx.serialization.Serializable | ||
|
|
||
| @Serializable | ||
| data class CloudVisionResponse( | ||
| val responses: List<AnnotateImageResponse>, | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class AnnotateImageResponse( | ||
| val fullTextAnnotation: FullTextAnnotation? = null, | ||
| ) | ||
|
|
||
| @Serializable | ||
| data class FullTextAnnotation( | ||
| val text: String? = null, | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.ninecraft.booket.core.ocr.service | ||
|
|
||
| import com.ninecraft.booket.core.ocr.model.CloudVisionRequest | ||
| import com.ninecraft.booket.core.ocr.model.CloudVisionResponse | ||
| import retrofit2.http.Body | ||
| import retrofit2.http.Header | ||
| import retrofit2.http.POST | ||
|
|
||
| interface CloudVisionService { | ||
| @POST("v1/images:annotate") | ||
| suspend fun batchAnnotateImage( | ||
| @Header("X-Goog-Api-Key") apiKey: String, | ||
| @Body body: CloudVisionRequest, | ||
| ): CloudVisionResponse | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| package com.ninecraft.booket.feature.record.ocr | ||
|
|
||
| import android.widget.Toast | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.ui.platform.LocalContext | ||
| import com.skydoves.compose.effects.RememberedEffect | ||
|
|
||
| @Composable | ||
| internal fun HandleOcrSideEffects( | ||
| state: OcrUiState, | ||
| ) { | ||
| val context = LocalContext.current | ||
|
|
||
| RememberedEffect(state.sideEffect) { | ||
| when (state.sideEffect) { | ||
| is OcrSideEffect.ShowToast -> { | ||
| Toast.makeText(context, state.sideEffect.message, Toast.LENGTH_SHORT).show() | ||
| } | ||
|
|
||
| null -> {} | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
buildConfigField에 따옴표 누락 → 빌드 실패/런타임 상수 생성 실패
Gradle의 buildConfigField는 세 번째 인자로 “코드 리터럴 문자열”을 기대합니다. 현재는 실제 키 값이 그대로 들어가 빌드시 문법 오류가 나거나 BuildConfig 상수가 잘못 생성될 수 있습니다. 문자열을 이스케이프하여 전달해주세요.
📝 Committable suggestion
🤖 Prompt for AI Agents