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
    }
}