TesseractOcrEngine.kt
178 lignes · 8163 octets
package fr.ebii.card2vcf.ocr import android.content.Context import android.graphics.Bitmap import android.os.SystemClock import android.util.Log import com.googlecode.tesseract.android.TessBaseAPI import fr.ebii.card2vcf.modeles.CatalogueModeles import fr.ebii.card2vcf.modeles.PreferencesModeles import java.io.File import java.io.FileOutputStream class TesseractOcrEngine( private val context: Context, private val prefs: PreferencesModeles = PreferencesModeles(context), /** PSM_AUTO par défaut : dernière configuration validée sur appareil. * PSM_SPARSE_TEXT reste comparable via le test instrumenté dédié. */ private val pageSegMode: Int = TessBaseAPI.PageSegMode.PSM_AUTO, ) : OcrEngine { override fun recognize(bitmap: Bitmap): OcrResult { val langues = languesActives() val languages = langues.joinToString("+") val dataPath = File(context.filesDir, "tesseract").absolutePath val t0 = SystemClock.elapsedRealtime() // Une seule init par scan (coûteuse : charge les traineddata) ; les passes // et orientations réutilisent l'instance via setImage. val api = TessBaseAPI() try { if (!api.init(dataPath, languages)) { throw OcrException("Impossible d'initialiser Tesseract ($languages)") } api.pageSegMode = pageSegMode val tInit = SystemClock.elapsedRealtime() // Passe 1 : image binarisée (contraste local) — décisive sur cartes colorées. val prepared = OcrPreprocessor.prepare(bitmap) val onPrepared = prepared?.let { candidate -> runCatching { OcrOrientation.recognizeBest(candidate) { recognizeOnce(api, it) } }.getOrNull().also { candidate.recycle() } } // Passe 2 : l'image originale concourt TOUJOURS — la binarisation peut // dégrader une carte propre tout en trouvant un email/téléphone. val onOriginal = runCatching { OcrOrientation.recognizeBest(bitmap) { recognizeOnce(api, it) } }.getOrNull() logDuree(t0, tInit, languages) return listOfNotNull(onPrepared, onOriginal).maxByOrNull { OcrOrientation.score(it) } ?: throw OcrException("OCR vide — reprenez la photo") } finally { api.recycle() } } private fun recognizeOnce(api: TessBaseAPI, bitmap: Bitmap): OcrResult { api.setImage(bitmap) val raw = api.utF8Text.orEmpty().trim() if (raw.isBlank()) throw OcrException("OCR vide") return OcrPostProcessor.process(raw).copy(spatialLines = extractSpatialLines(api)) } /** Lignes + boîtes + confiance via ResultIterator ; vide en cas d'échec (fallback texte plat). */ private fun extractSpatialLines(api: TessBaseAPI): List<OcrLine> = runCatching { val iterator = api.resultIterator ?: return@runCatching emptyList() val level = TessBaseAPI.PageIteratorLevel.RIL_TEXTLINE val lines = mutableListOf<OcrLine>() iterator.begin() do { val text = iterator.getUTF8Text(level)?.trim().orEmpty() if (text.isNotEmpty()) { val r = iterator.getBoundingRect(level) lines += OcrLine( text = text, box = OcrBox(r.left, r.top, r.right, r.bottom), confidence = iterator.confidence(level), ) } } while (iterator.next(level)) iterator.delete() lines.toList() }.getOrDefault(emptyList()) private fun logDuree(t0: Long, tInit: Long, languages: String) { val now = SystemClock.elapsedRealtime() Log.d(TAG, "scan OCR : init=${tInit - t0} ms, reco=${now - tInit} ms, total=${now - t0} ms ($languages)") } /** * Langues réellement chargeables : sélection de l'utilisateur ∩ modèles présents, * dans l'ordre du [CatalogueModeles]. * * Les `.traineddata` vivent dans `filesDir/tesseract/tessdata/` : * - 1ʳᵉ fois / changement `-Ptessdata=` → copie depuis les assets du APK (saveur `direct`) * - ensuite → réutilisation locale (téléchargement, sideload, remplacement sans rebuild) * - stamp `external` → ne jamais écraser : c'est le cas des modèles téléchargés par * [fr.ebii.card2vcf.modeles.DepotModeles], comme du dépôt manuel par adb * * La variante attendue est lue dans l'asset `tesseract/tessdata/variant.txt` * (écrit par la tâche Gradle `prepareTessdata`), pas via BuildConfig — la saveur * `fdroid` n'embarque aucun asset et retombe donc sur le seul contenu de filesDir. * * @throws ModelesManquantsException si aucune langue retenue n'est installée. */ internal fun languesActives(): List<String> { val tessData = File(context.filesDir, "tesseract/tessdata").apply { mkdirs() } val selection = selectionOrdonnee() syncDepuisAssets(tessData, selection) val presentes = selection.filter { estInstallee(tessData, it) } if (presentes.isEmpty()) throw ModelesManquantsException(selection) return presentes } /** Sélection utilisateur remise dans l'ordre du catalogue, pour un `fra+eng` déterministe. */ private fun selectionOrdonnee(): List<String> { val choisies = prefs.languesSelectionnees() .ifEmpty { CatalogueModeles.LANGUES_PAR_DEFAUT } return CatalogueModeles.tessdata.map { it.id }.filter { it in choisies } } private fun estInstallee(tessData: File, langue: String): Boolean = File(tessData, "$langue.traineddata").let { it.isFile && it.length() >= TAILLE_MINIMALE } private fun syncDepuisAssets(tessData: File, langs: List<String>) { val stamp = File(tessData, VARIANT_STAMP) val expectedVariant = packagedVariant() val stampValue = stamp.takeIf { it.isFile }?.readText()?.trim() val allPresent = langs.isNotEmpty() && langs.all { estInstallee(tessData, it) } val syncFromAssets = when { !allPresent -> true stampValue == null -> false.also { stamp.writeText(STAMP_EXTERNAL) } stampValue == STAMP_EXTERNAL -> false stampValue == expectedVariant -> false else -> true // ex. fast → full après réinstall } if (!syncFromAssets) return // Recopie systématique : un passage fast → full doit bien remplacer les fichiers // en place. Les modèles téléchargés sont protégés en amont par le stamp `external`. var copie = false for (lang in langs) { val assetPath = "tesseract/tessdata/$lang.traineddata" if (assetExists(assetPath)) { copyAsset(assetPath, File(tessData, "$lang.traineddata")) copie = true } } // Saveur `fdroid` : aucun asset, donc rien à marquer — le stamp `external` // éventuellement posé par un téléchargement doit rester intact. if (copie) stamp.writeText(expectedVariant) } private fun packagedVariant(): String = runCatching { context.assets.open(VARIANT_ASSET).bufferedReader().use { it.readText().trim() } }.getOrDefault("fast") private fun assetExists(assetPath: String): Boolean = runCatching { context.assets.open(assetPath).close(); true }.getOrDefault(false) private fun copyAsset(assetPath: String, target: File) { context.assets.open(assetPath).use { input -> FileOutputStream(target).use { output -> input.copyTo(output) } } } companion object { private const val TAG = "TesseractOcrEngine" private const val VARIANT_STAMP = ".card2vcf-tessdata-variant" private const val VARIANT_ASSET = "tesseract/tessdata/variant.txt" private const val STAMP_EXTERNAL = "external" /** En deçà, le fichier est un reliquat, pas un modèle exploitable. */ private const val TAILLE_MINIMALE = 10_000L } }
GitRust