-
Notifications
You must be signed in to change notification settings - Fork 0
Add OCR quality toggle with ML Kit and Tesseract options #1
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
71d0d99
1e21845
8e0d858
73bf347
15fb8bc
9f334f3
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 | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -11,13 +11,16 @@ import androidx.compose.runtime.LaunchedEffect | |||||||
| import androidx.compose.runtime.getValue | ||||||||
| import androidx.compose.runtime.mutableStateOf | ||||||||
| import androidx.compose.runtime.remember | ||||||||
| import androidx.compose.runtime.rememberCoroutineScope | ||||||||
| import androidx.compose.runtime.setValue | ||||||||
| import androidx.compose.ui.platform.LocalContext | ||||||||
| import androidx.navigation.NavHostController | ||||||||
| import androidx.navigation.compose.NavHost | ||||||||
| import androidx.navigation.compose.composable | ||||||||
| import androidx.navigation.compose.rememberNavController | ||||||||
| import com.skeler.scanely.ocr.OcrEngine | ||||||||
| import com.skeler.scanely.ocr.OcrHelper | ||||||||
| import com.skeler.scanely.ocr.OcrQuality | ||||||||
| import com.skeler.scanely.ocr.OcrResult | ||||||||
| import com.skeler.scanely.ui.components.GalleryPicker | ||||||||
| import com.skeler.scanely.ui.screens.CameraScreen | ||||||||
|
|
@@ -43,10 +46,13 @@ fun ScanelyNavigation( | |||||||
| navController: NavHostController = rememberNavController(), | ||||||||
| currentTheme: ThemeMode = ThemeMode.System, | ||||||||
| onThemeChanged: (ThemeMode) -> Unit = {}, | ||||||||
| ocrQuality: OcrQuality = OcrQuality.FAST, | ||||||||
| onOcrQualityChanged: (OcrQuality) -> Unit = {}, | ||||||||
| ocrLanguages: Set<String> = setOf("eng", "ara"), | ||||||||
| onOcrLanguagesChanged: (Set<String>) -> Unit = {} | ||||||||
| ) { | ||||||||
| val context = LocalContext.current | ||||||||
| val scope = rememberCoroutineScope() | ||||||||
|
|
||||||||
| // Shared OCR state | ||||||||
| var selectedImageUri by remember { mutableStateOf<Uri?>(null) } | ||||||||
|
|
@@ -56,14 +62,14 @@ fun ScanelyNavigation( | |||||||
| // Flag to control if OCR should run automatically when selectedImageUri changes | ||||||||
| var shouldAutoScan by remember { mutableStateOf(true) } | ||||||||
|
|
||||||||
| val ocrHelper = remember { OcrHelper(context) } | ||||||||
| val ocrEngine = remember { OcrEngine(context) } | ||||||||
|
||||||||
| val historyManager = remember { com.skeler.scanely.data.HistoryManager(context) } | ||||||||
|
|
||||||||
| // Auto-initialize on Language change | ||||||||
| LaunchedEffect(ocrLanguages) { | ||||||||
| // Auto-initialize on Quality or Language change | ||||||||
| LaunchedEffect(ocrQuality, ocrLanguages) { | ||||||||
| if (ocrLanguages.isNotEmpty()) { | ||||||||
| kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) { | ||||||||
| ocrHelper.reinitialize(ocrLanguages.toList()) | ||||||||
| ocrEngine.reinitialize(ocrQuality, ocrLanguages.toList()) | ||||||||
| } | ||||||||
| } | ||||||||
| } | ||||||||
|
|
@@ -85,12 +91,15 @@ fun ScanelyNavigation( | |||||||
|
|
||||||||
| navController.navigate(Routes.RESULTS) | ||||||||
|
|
||||||||
| kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch { | ||||||||
| scope.launch(kotlinx.coroutines.Dispatchers.IO) { | ||||||||
| try { | ||||||||
| // Ensure OCR is initialized | ||||||||
| if (!ocrHelper.isReady()) { | ||||||||
| // Get Tesseract helper for PDF processing (always use Tesseract for multi-language PDFs) | ||||||||
| val tesseractHelper = ocrEngine.getTesseractHelper() | ||||||||
|
Comment on lines
+96
to
+97
|
||||||||
|
|
||||||||
| // Ensure Tesseract is initialized | ||||||||
| if (!tesseractHelper.isReady()) { | ||||||||
| progressMessage = "Initializing OCR..." | ||||||||
| val initSuccess = ocrHelper.initialize(ocrLanguages.toList()) | ||||||||
| val initSuccess = tesseractHelper.initialize(ocrLanguages.toList()) | ||||||||
| if (!initSuccess) { | ||||||||
| isProcessing = false | ||||||||
| isPdfProcessing = false | ||||||||
|
|
@@ -103,7 +112,7 @@ fun ScanelyNavigation( | |||||||
| val pdfResult = com.skeler.scanely.ocr.PdfProcessor.extractTextFromPdf( | ||||||||
| context = context, | ||||||||
| pdfUri = uri, | ||||||||
| ocrHelper = ocrHelper, | ||||||||
| ocrHelper = tesseractHelper, | ||||||||
| enabledLanguages = ocrLanguages.toList(), | ||||||||
| onProgress = { update -> | ||||||||
| progressMessage = if (update.statusMessage.isNotEmpty()) { | ||||||||
|
|
@@ -154,6 +163,7 @@ fun ScanelyNavigation( | |||||||
| } | ||||||||
|
|
||||||||
| // Process image when selected (skip if PDF is being processed OR if shouldAutoScan is false) | ||||||||
| // Note: ocrQuality is intentionally NOT in dependencies - quality changes only affect new scans | ||||||||
| LaunchedEffect(selectedImageUri, isPdfProcessing, shouldAutoScan) { | ||||||||
|
Comment on lines
+166
to
167
|
||||||||
| // Note: ocrQuality is intentionally NOT in dependencies - quality changes only affect new scans | |
| LaunchedEffect(selectedImageUri, isPdfProcessing, shouldAutoScan) { | |
| LaunchedEffect(selectedImageUri, isPdfProcessing, shouldAutoScan, ocrQuality) { |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| package com.skeler.scanely.ocr | ||
|
|
||
| import android.content.Context | ||
| import android.graphics.Bitmap | ||
| import android.net.Uri | ||
| import android.util.Log | ||
| import com.google.mlkit.vision.common.InputImage | ||
| import com.google.mlkit.vision.text.TextRecognition | ||
| import com.google.mlkit.vision.text.TextRecognizer | ||
| import com.google.mlkit.vision.text.latin.TextRecognizerOptions | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.suspendCancellableCoroutine | ||
| import kotlinx.coroutines.sync.Mutex | ||
| import kotlinx.coroutines.sync.withLock | ||
| import kotlinx.coroutines.withContext | ||
| import kotlin.coroutines.resume | ||
|
|
||
| private const val TAG = "MlKitOcrHelper" | ||
|
|
||
| /** | ||
| * ML Kit Text Recognition OCR helper. | ||
| * Provides high-quality text recognition for Latin script. | ||
| * | ||
| * Uses Google's ML Kit which runs on-device for privacy. | ||
|
Comment on lines
+20
to
+24
|
||
| */ | ||
| class MlKitOcrHelper(private val context: Context) { | ||
|
|
||
| private val mutex = Mutex() | ||
| private var recognizer: TextRecognizer? = null | ||
| private var isInitialized = false | ||
|
|
||
| /** | ||
| * Initialize ML Kit Text Recognizer. | ||
| * ML Kit automatically handles model downloading and initialization. | ||
| * | ||
| * @param languages Ignored for ML Kit (uses Latin recognizer) | ||
| * @return true if initialization succeeded | ||
| */ | ||
| suspend fun initialize(languages: List<String> = emptyList()): Boolean = withContext(Dispatchers.IO) { | ||
| mutex.withLock { | ||
| try { | ||
| if (isInitialized && recognizer != null) { | ||
| return@withContext true | ||
| } | ||
|
|
||
| // Create text recognizer with Latin script options | ||
| recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS) | ||
| isInitialized = true | ||
|
|
||
| Log.d(TAG, "ML Kit Text Recognizer initialized successfully") | ||
| true | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "ML Kit initialization failed", e) | ||
| false | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Recognize text from an image URI. | ||
| */ | ||
| suspend fun recognizeText(imageUri: Uri): OcrResult? = withContext(Dispatchers.IO) { | ||
| if (!isInitialized || recognizer == null) { | ||
| Log.e(TAG, "MlKitOcrHelper not initialized") | ||
| return@withContext null | ||
| } | ||
|
|
||
| try { | ||
| val inputImage = InputImage.fromFilePath(context, imageUri) | ||
| recognizeFromInputImage(inputImage) | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "Failed to create InputImage from URI", e) | ||
| null | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Recognize text from a bitmap. | ||
| * Note: ML Kit handles thread safety internally, so no mutex is needed. | ||
|
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. This comment is misleading. According to the official ML Kit documentation, |
||
| */ | ||
| suspend fun recognizeText(bitmap: Bitmap): OcrResult? = withContext(Dispatchers.IO) { | ||
| if (!isInitialized || recognizer == null) { | ||
| Log.e(TAG, "MlKitOcrHelper not initialized") | ||
| return@withContext null | ||
| } | ||
|
|
||
| try { | ||
| val inputImage = InputImage.fromBitmap(bitmap, 0) | ||
| recognizeFromInputImage(inputImage) | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "Failed to create InputImage from Bitmap", e) | ||
| null | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Perform text recognition from InputImage. | ||
| * Uses mutex to ensure thread-safety since TextRecognizer is not thread-safe. | ||
| */ | ||
| private suspend fun recognizeFromInputImage(inputImage: InputImage): OcrResult? { | ||
| return mutex.withLock { | ||
| suspendCancellableCoroutine { continuation -> | ||
| val startTime = System.currentTimeMillis() | ||
|
|
||
| recognizer?.process(inputImage) | ||
| ?.addOnSuccessListener { visionText -> | ||
| val processingTime = System.currentTimeMillis() - startTime | ||
|
|
||
| val text = visionText.text | ||
|
|
||
| // Calculate confidence from element-level confidence scores | ||
| val confidences = visionText.textBlocks.flatMap { block -> | ||
| block.lines.flatMap { line -> | ||
| line.elements.mapNotNull { it.confidence } | ||
| } | ||
| } | ||
|
|
||
| val avgConfidence = if (confidences.isNotEmpty()) { | ||
| (confidences.average() * 100).toInt() | ||
| } else { | ||
| 0 | ||
| } | ||
|
|
||
| Log.d(TAG, "OCR completed in ${processingTime}ms, confidence: $avgConfidence%") | ||
|
|
||
| continuation.resume( | ||
| OcrResult( | ||
| text = text, | ||
| confidence = avgConfidence, | ||
| languages = listOf("eng"), // ML Kit Latin | ||
| processingTimeMs = processingTime | ||
| ) | ||
| ) | ||
| } | ||
| ?.addOnFailureListener { e -> | ||
| Log.e(TAG, "ML Kit text recognition failed", e) | ||
| continuation.resume(null) | ||
| } | ||
| ?: continuation.resume(null) | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+100
to
+142
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. According to the official ML Kit documentation, To ensure thread safety, you should wrap the logic in return mutex.withLock {
suspendCancellableCoroutine { continuation ->
val startTime = System.currentTimeMillis()
recognizer?.process(inputImage)
?.addOnSuccessListener { visionText ->
val processingTime = System.currentTimeMillis() - startTime
val text = visionText.text
// Calculate confidence from block-level confidence
val confidences = visionText.textBlocks.mapNotNull { block ->
block.lines.flatMap { line ->
line.elements.mapNotNull { it.confidence }
}
}.flatten()
val avgConfidence = if (confidences.isNotEmpty()) {
(confidences.average() * 100).toInt()
} else {
0
}
Log.d(TAG, "OCR completed in ${processingTime}ms, confidence: $avgConfidence%")
continuation.resume(
OcrResult(
text = text,
confidence = avgConfidence,
languages = listOf("eng"), // ML Kit Latin
processingTimeMs = processingTime
)
)
}
?.addOnFailureListener { e ->
Log.e(TAG, "ML Kit text recognition failed", e)
continuation.resume(null)
}
?: continuation.resume(null)
}
} |
||
|
|
||
| /** | ||
| * Check if the helper is ready. | ||
| */ | ||
| fun isReady(): Boolean = isInitialized && recognizer != null | ||
|
|
||
| /** | ||
| * Release ML Kit resources. | ||
| */ | ||
| fun release() { | ||
| try { | ||
| recognizer?.close() | ||
| recognizer = null | ||
| isInitialized = false | ||
| Log.d(TAG, "ML Kit resources released") | ||
| } catch (e: Exception) { | ||
| Log.e(TAG, "Error releasing ML Kit", e) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Reinitialize (for compatibility with OcrHelper interface). | ||
| */ | ||
| suspend fun reinitialize(languages: List<String>): Boolean { | ||
| release() | ||
| return initialize(languages) | ||
| } | ||
| } | ||
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.
The logic to load the OCR quality preference from SharedPreferences can be simplified. The
getStringmethod with a default value will never returnnull, making the elvis operator (?:) on line 39 redundant. You can make this logic more concise and idiomatic usingrunCatching.