amelioration de l ocr
EBO <eric.bouhana@softalys.com> committé le 2026-09-13 15:22
3f5eee121d6123e25d13f0eb499b2a85261d574e
1 parent(s)
13 fichiers modifiés
+575
-31
M
android/app/src/androidTest/java/fr/ebii/card2vcf/ocr/CarteColoreeOcrInstrumentedTest.kt
+5
-0
@@ -37,10 +37,15 @@ class CarteColoreeOcrInstrumentedTest {
| 37 | 37 | canvas.drawText("Tel. 04 75 35 12 34", 120f, 600f, paint) |
| 38 | 38 | canvas.drawText("sonia.martin@voltea.fr", 120f, 720f, paint) |
| 39 | 39 | |
| 40 | + val t0 = android.os.SystemClock.elapsedRealtime() | |
| 40 | 41 | val ocr = TesseractOcrEngine(context).recognize(bmp) |
| 42 | + println("=== durée scan carte colorée : ${android.os.SystemClock.elapsedRealtime() - t0} ms") | |
| 41 | 43 | |
| 42 | 44 | println("=== OCR RAW (carte colorée) ===\n${ocr.rawText}") |
| 43 | 45 | println("=== phones=${ocr.phones} emails=${ocr.emails}") |
| 46 | + println("=== spatialLines=${ocr.spatialLines.map { "${it.text}@${it.box}" }}") | |
| 47 | + | |
| 48 | + assertTrue("spatialLines attendues", ocr.spatialLines.isNotEmpty()) | |
| 44 | 49 | |
| 45 | 50 | assertTrue( |
| 46 | 51 | "téléphone attendu absent: ${ocr.phones}", |
M
android/app/src/androidTest/java/fr/ebii/card2vcf/ocr/DimoCardOcrInstrumentedTest.kt
+36
-0
@@ -43,6 +43,42 @@ class DimoCardOcrInstrumentedTest {
| 43 | 43 | ocr.emails.any { it.contains("dimosoftware", ignoreCase = true) } |
| 44 | 44 | || ocr.rawText.contains("dimosoftware", ignoreCase = true), |
| 45 | 45 | ) |
| 46 | + | |
| 47 | + // Extraction spatiale (ResultIterator) : lignes avec boîtes cohérentes. | |
| 48 | + assertTrue("spatialLines attendues", ocr.spatialLines.isNotEmpty()) | |
| 49 | + assertTrue( | |
| 50 | + "boîtes invalides: ${ocr.spatialLines.map { it.box }}", | |
| 51 | + ocr.spatialLines.all { it.box.height > 0 && it.box.width > 0 }, | |
| 52 | + ) | |
| 53 | + assertTrue( | |
| 54 | + "confiances hors bornes", | |
| 55 | + ocr.spatialLines.all { it.confidence in 0f..100f }, | |
| 56 | + ) | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** Comparaison A/B des modes de segmentation — lire les scores dans le stdout du test. */ | |
| 60 | + @Test | |
| 61 | + fun comparePsmAutoEtSparseText() { | |
| 62 | + val context = InstrumentationRegistry.getInstrumentation().targetContext | |
| 63 | + OpenCvScanEngine.ensureOpenCv() | |
| 64 | + val assets = InstrumentationRegistry.getInstrumentation().context.assets | |
| 65 | + val jpeg = assets.open("cards/dimo_genoux.jpg").use { it.readBytes() } | |
| 66 | + val oriented = ImageOrientation.fromJpegBytes(jpeg)!! | |
| 67 | + val scanned = OpenCvScanEngine().scan(oriented.bitmap) | |
| 68 | + | |
| 69 | + val modes = listOf( | |
| 70 | + "PSM_AUTO" to com.googlecode.tesseract.android.TessBaseAPI.PageSegMode.PSM_AUTO, | |
| 71 | + "PSM_SPARSE_TEXT" to com.googlecode.tesseract.android.TessBaseAPI.PageSegMode.PSM_SPARSE_TEXT, | |
| 72 | + ) | |
| 73 | + for ((nom, mode) in modes) { | |
| 74 | + val t0 = android.os.SystemClock.elapsedRealtime() | |
| 75 | + val ocr = TesseractOcrEngine(context, pageSegMode = mode).recognize(scanned.bitmap) | |
| 76 | + val duree = android.os.SystemClock.elapsedRealtime() - t0 | |
| 77 | + println( | |
| 78 | + "=== $nom : score=${OcrOrientation.score(ocr)} durée=${duree}ms " + | |
| 79 | + "phones=${ocr.phones} emails=${ocr.emails} lignes=${ocr.lines.size}", | |
| 80 | + ) | |
| 81 | + } | |
| 46 | 82 | } |
| 47 | 83 | |
| 48 | 84 | @Test |
M
android/app/src/main/java/fr/ebii/card2vcf/contact/ContactCard.kt
+2
-0
@@ -11,6 +11,8 @@ data class ContactCard(
| 11 | 11 | val website: String? = null, |
| 12 | 12 | val address: String? = null, |
| 13 | 13 | val note: String? = null, |
| 14 | + /** Champs dont la confiance OCR est basse (clés : fullName, company, jobTitle, address). */ | |
| 15 | + val champsDouteux: Set<String> = emptySet(), | |
| 14 | 16 | ) { |
| 15 | 17 | fun displayName(): String = |
| 16 | 18 | fullName?.takeIf { it.isNotBlank() } |
M
android/app/src/main/java/fr/ebii/card2vcf/contact/ContactDraftMerge.kt
+1
-0
@@ -23,6 +23,7 @@ object ContactDraftMerge {
| 23 | 23 | website = prefer(ocr.urls.firstOrNull(), prefer(base.website, heuristic.website)), |
| 24 | 24 | address = prefer(base.address, heuristic.address), |
| 25 | 25 | note = prefer(base.note, heuristic.note), |
| 26 | + champsDouteux = heuristic.champsDouteux, | |
| 26 | 27 | ) |
| 27 | 28 | } |
| 28 | 29 |
M
android/app/src/main/java/fr/ebii/card2vcf/contact/ContactHeuristicParser.kt
+109
-2
@@ -1,5 +1,8 @@
| 1 | 1 | package fr.ebii.card2vcf.contact |
| 2 | 2 | |
| 3 | +import fr.ebii.card2vcf.ocr.BlockGrouper | |
| 4 | +import fr.ebii.card2vcf.ocr.OcrLine | |
| 5 | +import fr.ebii.card2vcf.ocr.OcrPostProcessor | |
| 3 | 6 | import fr.ebii.card2vcf.ocr.OcrResult |
| 4 | 7 | |
| 5 | 8 | /** |
@@ -8,6 +11,9 @@ import fr.ebii.card2vcf.ocr.OcrResult
| 8 | 11 | */ |
| 9 | 12 | object ContactHeuristicParser { |
| 10 | 13 | |
| 14 | + /** Confiance Tesseract (0-100) sous laquelle un champ est marqué « à vérifier ». */ | |
| 15 | + private const val SEUIL_CONFIANCE = 70f | |
| 16 | + | |
| 11 | 17 | private val jobKeywords = Regex( |
| 12 | 18 | """(?i)\b(directeur|directrice|ceo|cto|cfo|coo|président|presidente|""" + |
| 13 | 19 | """manager|responsable|ingénieur|ingenieur|commercial|consultan[te]?|""" + |
@@ -31,6 +37,7 @@ object ContactHeuristicParser {
| 31 | 37 | ) |
| 32 | 38 | |
| 33 | 39 | fun parse(ocr: OcrResult): ContactCard { |
| 40 | + if (ocr.spatialLines.isNotEmpty()) return parseSpatial(ocr) | |
| 34 | 41 | val lines = ocr.lines.ifEmpty { ocr.rawText.lines().map { it.trim() }.filter { it.isNotEmpty() } } |
| 35 | 42 | val residual = lines.toMutableList() |
| 36 | 43 |
@@ -118,8 +125,11 @@ object ContactHeuristicParser {
| 118 | 125 | return if (lastCaps > 0) { |
| 119 | 126 | val last = titleWord(parts[lastCaps]) |
| 120 | 127 | val first = parts.take(lastCaps).joinToString(" ") { titleWord(it) } |
| 121 | - // Conserve les prénoms composés sans forcer une casse bizarre si déjà mixtes | |
| 122 | - val firstKeep = parts.take(lastCaps).joinToString(" ") | |
| 128 | + // Conserve les prénoms composés sans forcer une casse bizarre si déjà mixtes ; | |
| 129 | + // mais un token tout en capitales (« SONIA MARTIN ») est remis en Title Case. | |
| 130 | + val firstKeep = parts.take(lastCaps).joinToString(" ") { w -> | |
| 131 | + if (w.length >= 2 && w == w.uppercase()) titleWord(w) else w | |
| 132 | + } | |
| 123 | 133 | Triple(firstKeep, last, "$firstKeep $last") |
| 124 | 134 | } else if (parts.size >= 2) { |
| 125 | 135 | val last = titleWord(parts.last()) |
@@ -164,6 +174,103 @@ object ContactHeuristicParser {
| 164 | 174 | private fun cleanAddress(line: String?): String? = |
| 165 | 175 | line?.replace(Regex("""\s+"""), " ")?.trim()?.ifBlank { null } |
| 166 | 176 | |
| 177 | + /** | |
| 178 | + * Chemin spatial : exploite boîtes/hauteurs/blocs (ResultIterator) au lieu du | |
| 179 | + * texte plat. Mêmes regex de classification que le chemin legacy, enrichies | |
| 180 | + * des signaux de mise en page (taille de police, regroupement, libellés). | |
| 181 | + */ | |
| 182 | + private fun parseSpatial(ocr: OcrResult): ContactCard { | |
| 183 | + val blocks = BlockGrouper.group(ocr.spatialLines) | |
| 184 | + // « Hauteur typique » = médiane basse : robuste aux logos géants et aux | |
| 185 | + // cartes à 2 lignes où la médiane vraie serait tirée vers le haut. | |
| 186 | + val typicalHeight = ocr.spatialLines.map { it.box.height }.sorted() | |
| 187 | + .let { it[(it.size - 1) / 2] } | |
| 188 | + | |
| 189 | + fun isData(t: String) = emailOrUrlOrPhone.containsMatchIn(t) | |
| 190 | + fun isLabel(t: String) = skipLine.containsMatchIn(t) | |
| 191 | + | |
| 192 | + // Libellé seul (Tél. : / Mobile…) → la valeur est sur la ligne suivante du bloc. | |
| 193 | + val phones = ocr.phones.toMutableList() | |
| 194 | + for (block in blocks) { | |
| 195 | + block.lines.zipWithNext { label, value -> | |
| 196 | + if (isLabel(label.text) && !isData(label.text)) { | |
| 197 | + OcrPostProcessor.process(value.text).phones.forEach { p -> | |
| 198 | + if (p !in phones) phones += p | |
| 199 | + } | |
| 200 | + } | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + // Adresse : le bloc le plus « adresse », jointure multi-lignes. | |
| 205 | + val addressBlock = blocks | |
| 206 | + .filter { b -> b.lines.any { addressKeywords.containsMatchIn(it.text) && !isData(it.text) } } | |
| 207 | + .maxByOrNull { b -> b.lines.count { addressKeywords.containsMatchIn(it.text) && !isData(it.text) } } | |
| 208 | + val address = addressBlock?.lines | |
| 209 | + ?.filter { !isData(it.text) && !isLabel(it.text) } | |
| 210 | + ?.joinToString(", ") { it.text.replace(Regex("""\s+"""), " ").trim() } | |
| 211 | + ?.ifBlank { null } | |
| 212 | + val addressLines = addressBlock?.lines?.toSet().orEmpty() | |
| 213 | + | |
| 214 | + val rest = ocr.spatialLines.filter { it !in addressLines && !isData(it.text) && !isLabel(it.text) } | |
| 215 | + | |
| 216 | + val jobLine = rest.firstOrNull { jobKeywords.containsMatchIn(it.text) } | |
| 217 | + | |
| 218 | + val emailDomain = ocr.emails.firstOrNull() | |
| 219 | + ?.substringAfter('@', "")?.substringBefore('.')?.takeIf { it.length >= 3 } | |
| 220 | + | |
| 221 | + // NB : « tout en majuscules » n'est PAS un critère société — les noms de | |
| 222 | + // personnes sont très souvent en capitales sur les cartes françaises. | |
| 223 | + fun looksCompanySpatial(l: OcrLine) = companyKeywords.containsMatchIn(l.text) | |
| 224 | + || (emailDomain != null && l.text.lowercase().replace(" ", "").contains(emailDomain)) | |
| 225 | + | |
| 226 | + val nameLine = rest | |
| 227 | + .filter { it !== jobLine && !looksCompanySpatial(it) && personLikeSpatial(it, typicalHeight) } | |
| 228 | + .maxByOrNull { personScore(it.text) + it.box.height * 40 / typicalHeight } | |
| 229 | + val (firstName, lastName, fullName) = splitPersonName(nameLine?.text) | |
| 230 | + | |
| 231 | + val companyLine = rest | |
| 232 | + .filter { it !== jobLine && it !== nameLine && looksCompanySpatial(it) } | |
| 233 | + .maxByOrNull { it.box.height * 1000 - it.box.top } // grand et en haut de carte | |
| 234 | + val company = cleanCompany(companyLine?.text) | |
| 235 | + ?: ocr.emails.firstOrNull()?.let { companyFromEmailDomain(it) } | |
| 236 | + | |
| 237 | + val noteParts = rest | |
| 238 | + .filter { it !== jobLine && it !== nameLine && it !== companyLine && it.text.length > 2 } | |
| 239 | + | |
| 240 | + val douteux = buildSet { | |
| 241 | + if (nameLine != null && nameLine.confidence < SEUIL_CONFIANCE) add("fullName") | |
| 242 | + if (jobLine != null && jobLine.confidence < SEUIL_CONFIANCE) add("jobTitle") | |
| 243 | + if (companyLine != null && companyLine.confidence < SEUIL_CONFIANCE) add("company") | |
| 244 | + if (addressBlock != null && addressBlock.avgConfidence() < SEUIL_CONFIANCE) add("address") | |
| 245 | + } | |
| 246 | + | |
| 247 | + return ContactCard( | |
| 248 | + fullName = fullName, | |
| 249 | + firstName = firstName, | |
| 250 | + lastName = lastName, | |
| 251 | + company = company, | |
| 252 | + jobTitle = cleanJob(jobLine?.text), | |
| 253 | + phones = phones, | |
| 254 | + emails = ocr.emails, | |
| 255 | + website = ocr.urls.firstOrNull(), | |
| 256 | + address = address, | |
| 257 | + note = noteParts.take(3).joinToString(" · ") { it.text }.ifBlank { null }, | |
| 258 | + champsDouteux = douteux, | |
| 259 | + ) | |
| 260 | + } | |
| 261 | + | |
| 262 | + /** Comme [looksLikePersonName], avec dispense de majuscules pour les gros caractères. */ | |
| 263 | + private fun personLikeSpatial(line: OcrLine, typicalHeight: Int): Boolean { | |
| 264 | + val t = line.text.replace(Regex("""[^\p{L}\s\-']"""), " ").trim() | |
| 265 | + if (t.length !in 4..60) return false | |
| 266 | + if (jobKeywords.containsMatchIn(t) || companyKeywords.containsMatchIn(t)) return false | |
| 267 | + if (addressKeywords.containsMatchIn(t)) return false | |
| 268 | + val parts = t.split(Regex("""\s+""")).filter { it.isNotBlank() } | |
| 269 | + if (parts.size !in 2..4) return false | |
| 270 | + val caps = parts.count { it.first().isUpperCase() } | |
| 271 | + return caps >= 2 || line.box.height >= 1.5 * typicalHeight | |
| 272 | + } | |
| 273 | + | |
| 167 | 274 | internal fun companyFromEmailDomain(email: String): String? { |
| 168 | 275 | val domain = email.substringAfter('@', "").substringBefore('.') |
| 169 | 276 | if (domain.length < 3) return null |
A
android/app/src/main/java/fr/ebii/card2vcf/ocr/BlockGrouper.kt
+40
-0
@@ -0,0 +1,40 @@
| 1 | +package fr.ebii.card2vcf.ocr | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Regroupe les lignes OCR en blocs spatiaux : une ligne rejoint le bloc le plus | |
| 5 | + * proche qui la chevauche horizontalement (même colonne) si l'écart vertical | |
| 6 | + * reste sous [GAP_FACTOR] × hauteur de ligne médiane. La médiane rend le seuil | |
| 7 | + * robuste aux lignes géantes (logos). O(n × blocs), n ≈ 10-40 lignes. | |
| 8 | + */ | |
| 9 | +object BlockGrouper { | |
| 10 | + private const val GAP_FACTOR = 1.8 | |
| 11 | + | |
| 12 | + fun group(lines: List<OcrLine>): List<OcrBlock> { | |
| 13 | + if (lines.isEmpty()) return emptyList() | |
| 14 | + val sorted = lines.sortedWith(compareBy({ it.box.top }, { it.box.left })) | |
| 15 | + val maxGap = medianHeight(sorted) * GAP_FACTOR | |
| 16 | + | |
| 17 | + val blocks = mutableListOf<MutableList<OcrLine>>() | |
| 18 | + for (line in sorted) { | |
| 19 | + val candidate = blocks | |
| 20 | + .filter { block -> | |
| 21 | + val blockBox = block.map { it.box }.reduce(OcrBox::union) | |
| 22 | + blockBox.overlapsHorizontally(line.box) && | |
| 23 | + line.box.top - blockBox.bottom <= maxGap | |
| 24 | + } | |
| 25 | + .maxByOrNull { block -> block.maxOf { it.box.bottom } } | |
| 26 | + if (candidate != null) candidate += line else blocks += mutableListOf(line) | |
| 27 | + } | |
| 28 | + | |
| 29 | + return blocks | |
| 30 | + .map { OcrBlock(it) } | |
| 31 | + .sortedWith(compareBy({ it.box().top }, { it.box().left })) | |
| 32 | + } | |
| 33 | + | |
| 34 | + private fun medianHeight(lines: List<OcrLine>): Double { | |
| 35 | + val heights = lines.map { it.box.height }.sorted() | |
| 36 | + val mid = heights.size / 2 | |
| 37 | + return if (heights.size % 2 == 1) heights[mid].toDouble() | |
| 38 | + else (heights[mid - 1] + heights[mid]) / 2.0 | |
| 39 | + } | |
| 40 | +} |
A
android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrLine.kt
+34
-0
@@ -0,0 +1,34 @@
| 1 | +package fr.ebii.card2vcf.ocr | |
| 2 | + | |
| 3 | +/** | |
| 4 | + * Boîte englobante en pixels de l'image OCRisée (repère y vers le bas). | |
| 5 | + * Data class maison (pas android.graphics.Rect) : le modèle et le parseur | |
| 6 | + * restent testables en JVM pure. Seules les positions RELATIVES comptent | |
| 7 | + * (hauteurs, écarts, alignements) — le repère absolu dépend du candidat | |
| 8 | + * (prétraitement, orientation) mais est cohérent au sein d'un même résultat. | |
| 9 | + */ | |
| 10 | +data class OcrBox(val left: Int, val top: Int, val right: Int, val bottom: Int) { | |
| 11 | + val height: Int get() = bottom - top | |
| 12 | + val width: Int get() = right - left | |
| 13 | + | |
| 14 | + fun overlapsHorizontally(other: OcrBox): Boolean = | |
| 15 | + left < other.right && other.left < right | |
| 16 | + | |
| 17 | + fun union(other: OcrBox): OcrBox = OcrBox( | |
| 18 | + minOf(left, other.left), | |
| 19 | + minOf(top, other.top), | |
| 20 | + maxOf(right, other.right), | |
| 21 | + maxOf(bottom, other.bottom), | |
| 22 | + ) | |
| 23 | +} | |
| 24 | + | |
| 25 | +/** Ligne de texte reconnue avec sa boîte et la confiance Tesseract (0-100). */ | |
| 26 | +data class OcrLine(val text: String, val box: OcrBox, val confidence: Float) | |
| 27 | + | |
| 28 | +/** Groupe de lignes spatialement cohérent (pavé d'adresse, bloc contact…). */ | |
| 29 | +data class OcrBlock(val lines: List<OcrLine>) { | |
| 30 | + fun text(): String = lines.joinToString("\n") { it.text } | |
| 31 | + fun box(): OcrBox = lines.map { it.box }.reduce(OcrBox::union) | |
| 32 | + fun avgConfidence(): Float = | |
| 33 | + if (lines.isEmpty()) 0f else lines.map { it.confidence }.average().toFloat() | |
| 34 | +} |
M
android/app/src/main/java/fr/ebii/card2vcf/ocr/OcrResult.kt
+2
-0
@@ -7,4 +7,6 @@ data class OcrResult(
| 7 | 7 | val emails: List<String> = emptyList(), |
| 8 | 8 | val urls: List<String> = emptyList(), |
| 9 | 9 | val confidence: Float? = null, |
| 10 | + /** Lignes avec boîtes englobantes (ResultIterator) ; vide = pipeline texte plat. */ | |
| 11 | + val spatialLines: List<OcrLine> = emptyList(), | |
| 10 | 12 | ) |
M
android/app/src/main/java/fr/ebii/card2vcf/ocr/TesseractOcrEngine.kt
+61
-25
@@ -2,51 +2,86 @@ package fr.ebii.card2vcf.ocr
| 2 | 2 | |
| 3 | 3 | import android.content.Context |
| 4 | 4 | import android.graphics.Bitmap |
| 5 | +import android.os.SystemClock | |
| 6 | +import android.util.Log | |
| 5 | 7 | import com.googlecode.tesseract.android.TessBaseAPI |
| 6 | 8 | import java.io.File |
| 7 | 9 | import java.io.FileOutputStream |
| 8 | 10 | |
| 9 | 11 | class TesseractOcrEngine( |
| 10 | 12 | private val context: Context, |
| 11 | - private val languages: String = "fra+deu+eng+spa+por+ita+pol", | |
| 13 | + private val languages: String = "fra+eng", | |
| 14 | + /** PSM_AUTO par défaut : dernière configuration validée sur appareil. | |
| 15 | + * PSM_SPARSE_TEXT reste comparable via le test instrumenté dédié. */ | |
| 16 | + private val pageSegMode: Int = TessBaseAPI.PageSegMode.PSM_AUTO, | |
| 12 | 17 | ) : OcrEngine { |
| 13 | 18 | |
| 14 | 19 | override fun recognize(bitmap: Bitmap): OcrResult { |
| 15 | 20 | val dataPath = ensureTessData() |
| 16 | - // Passe 1 : image binarisée (contraste local) — décisive sur cartes colorées. | |
| 17 | - val prepared = OcrPreprocessor.prepare(bitmap) | |
| 18 | - val onPrepared = prepared?.let { candidate -> | |
| 19 | - runCatching { | |
| 20 | - OcrOrientation.recognizeBest(candidate) { recognizeOnce(it, dataPath) } | |
| 21 | - }.getOrNull().also { candidate.recycle() } | |
| 22 | - } | |
| 23 | - if (onPrepared != null && (onPrepared.emails.isNotEmpty() || onPrepared.phones.isNotEmpty())) { | |
| 24 | - return onPrepared | |
| 25 | - } | |
| 26 | - // Passe 2 : image originale, puis meilleur score des deux. | |
| 27 | - val onOriginal = runCatching { | |
| 28 | - OcrOrientation.recognizeBest(bitmap) { recognizeOnce(it, dataPath) } | |
| 29 | - }.getOrNull() | |
| 30 | - return listOfNotNull(onPrepared, onOriginal).maxByOrNull { OcrOrientation.score(it) } | |
| 31 | - ?: throw OcrException("OCR vide — reprenez la photo") | |
| 32 | - } | |
| 33 | - | |
| 34 | - private fun recognizeOnce(bitmap: Bitmap, dataPath: String): OcrResult { | |
| 21 | + val t0 = SystemClock.elapsedRealtime() | |
| 22 | + // Une seule init par scan (coûteuse : charge les traineddata) ; les passes | |
| 23 | + // et orientations réutilisent l'instance via setImage. | |
| 35 | 24 | val api = TessBaseAPI() |
| 36 | 25 | try { |
| 37 | 26 | if (!api.init(dataPath, languages)) { |
| 38 | 27 | throw OcrException("Impossible d'initialiser Tesseract ($languages)") |
| 39 | 28 | } |
| 40 | - api.pageSegMode = TessBaseAPI.PageSegMode.PSM_AUTO | |
| 41 | - api.setImage(bitmap) | |
| 42 | - val raw = api.utF8Text.orEmpty().trim() | |
| 43 | - if (raw.isBlank()) throw OcrException("OCR vide") | |
| 44 | - return OcrPostProcessor.process(raw) | |
| 29 | + api.pageSegMode = pageSegMode | |
| 30 | + val tInit = SystemClock.elapsedRealtime() | |
| 31 | + | |
| 32 | + // Passe 1 : image binarisée (contraste local) — décisive sur cartes colorées. | |
| 33 | + val prepared = OcrPreprocessor.prepare(bitmap) | |
| 34 | + val onPrepared = prepared?.let { candidate -> | |
| 35 | + runCatching { | |
| 36 | + OcrOrientation.recognizeBest(candidate) { recognizeOnce(api, it) } | |
| 37 | + }.getOrNull().also { candidate.recycle() } | |
| 38 | + } | |
| 39 | + // Passe 2 : l'image originale concourt TOUJOURS — la binarisation peut | |
| 40 | + // dégrader une carte propre tout en trouvant un email/téléphone. | |
| 41 | + val onOriginal = runCatching { | |
| 42 | + OcrOrientation.recognizeBest(bitmap) { recognizeOnce(api, it) } | |
| 43 | + }.getOrNull() | |
| 44 | + logDuree(t0, tInit) | |
| 45 | + return listOfNotNull(onPrepared, onOriginal).maxByOrNull { OcrOrientation.score(it) } | |
| 46 | + ?: throw OcrException("OCR vide — reprenez la photo") | |
| 45 | 47 | } finally { |
| 46 | 48 | api.recycle() |
| 47 | 49 | } |
| 48 | 50 | } |
| 49 | 51 | |
| 52 | + private fun recognizeOnce(api: TessBaseAPI, bitmap: Bitmap): OcrResult { | |
| 53 | + api.setImage(bitmap) | |
| 54 | + val raw = api.utF8Text.orEmpty().trim() | |
| 55 | + if (raw.isBlank()) throw OcrException("OCR vide") | |
| 56 | + return OcrPostProcessor.process(raw).copy(spatialLines = extractSpatialLines(api)) | |
| 57 | + } | |
| 58 | + | |
| 59 | + /** Lignes + boîtes + confiance via ResultIterator ; vide en cas d'échec (fallback texte plat). */ | |
| 60 | + private fun extractSpatialLines(api: TessBaseAPI): List<OcrLine> = runCatching { | |
| 61 | + val iterator = api.resultIterator ?: return@runCatching emptyList() | |
| 62 | + val level = TessBaseAPI.PageIteratorLevel.RIL_TEXTLINE | |
| 63 | + val lines = mutableListOf<OcrLine>() | |
| 64 | + iterator.begin() | |
| 65 | + do { | |
| 66 | + val text = iterator.getUTF8Text(level)?.trim().orEmpty() | |
| 67 | + if (text.isNotEmpty()) { | |
| 68 | + val r = iterator.getBoundingRect(level) | |
| 69 | + lines += OcrLine( | |
| 70 | + text = text, | |
| 71 | + box = OcrBox(r.left, r.top, r.right, r.bottom), | |
| 72 | + confidence = iterator.confidence(level), | |
| 73 | + ) | |
| 74 | + } | |
| 75 | + } while (iterator.next(level)) | |
| 76 | + iterator.delete() | |
| 77 | + lines.toList() | |
| 78 | + }.getOrDefault(emptyList()) | |
| 79 | + | |
| 80 | + private fun logDuree(t0: Long, tInit: Long) { | |
| 81 | + val now = SystemClock.elapsedRealtime() | |
| 82 | + Log.d(TAG, "scan OCR : init=${tInit - t0} ms, reco=${now - tInit} ms, total=${now - t0} ms ($languages)") | |
| 83 | + } | |
| 84 | + | |
| 50 | 85 | /** |
| 51 | 86 | * Les `.traineddata` vivent dans `filesDir/tesseract/tessdata/` : |
| 52 | 87 | * - 1ʳᵉ fois / changement `-Ptessdata=` → copie depuis les assets du APK |
@@ -109,6 +144,7 @@ class TesseractOcrEngine(
| 109 | 144 | } |
| 110 | 145 | |
| 111 | 146 | companion object { |
| 147 | + private const val TAG = "TesseractOcrEngine" | |
| 112 | 148 | private const val VARIANT_STAMP = ".card2vcf-tessdata-variant" |
| 113 | 149 | private const val VARIANT_ASSET = "tesseract/tessdata/variant.txt" |
| 114 | 150 | private const val STAMP_EXTERNAL = "external" |
M
android/app/src/main/java/fr/ebii/card2vcf/ui/ContactDraftFields.kt
+19
-4
@@ -22,14 +22,20 @@ fun ContactDraftFields(
| 22 | 22 | // Pas de verticalScroll ici : le parent ContactDraftScreen scrolle déjà. |
| 23 | 23 | // Claviers adaptés + focus chaîné (ImeAction.Next), champs délimités via ChampTexte. |
| 24 | 24 | val suivant = KeyboardOptions(imeAction = ImeAction.Next) |
| 25 | + // Champs à confiance OCR basse : sous-texte « à vérifier », effacé dès correction. | |
| 26 | + val aVerifier = stringResource(R.string.scan_champ_a_verifier) | |
| 27 | + fun sousTexte(cle: String): String? = if (cle in card.champsDouteux) aVerifier else null | |
| 25 | 28 | Column( |
| 26 | 29 | modifier, |
| 27 | 30 | verticalArrangement = Arrangement.spacedBy(10.dp), |
| 28 | 31 | ) { |
| 29 | 32 | ChampTexte( |
| 30 | 33 | value = card.fullName.orEmpty(), |
| 31 | - onValueChange = { onChange(card.copy(fullName = it.ifBlank { null })) }, | |
| 34 | + onValueChange = { | |
| 35 | + onChange(card.copy(fullName = it.ifBlank { null }, champsDouteux = card.champsDouteux - "fullName")) | |
| 36 | + }, | |
| 32 | 37 | label = stringResource(R.string.scan_champ_nom), |
| 38 | + supportingText = sousTexte("fullName"), | |
| 33 | 39 | keyboardOptions = suivant, |
| 34 | 40 | ) |
| 35 | 41 | ChampTexte( |
@@ -46,14 +52,20 @@ fun ContactDraftFields(
| 46 | 52 | ) |
| 47 | 53 | ChampTexte( |
| 48 | 54 | value = card.company.orEmpty(), |
| 49 | - onValueChange = { onChange(card.copy(company = it.ifBlank { null })) }, | |
| 55 | + onValueChange = { | |
| 56 | + onChange(card.copy(company = it.ifBlank { null }, champsDouteux = card.champsDouteux - "company")) | |
| 57 | + }, | |
| 50 | 58 | label = stringResource(R.string.scan_champ_societe), |
| 59 | + supportingText = sousTexte("company"), | |
| 51 | 60 | keyboardOptions = suivant, |
| 52 | 61 | ) |
| 53 | 62 | ChampTexte( |
| 54 | 63 | value = card.jobTitle.orEmpty(), |
| 55 | - onValueChange = { onChange(card.copy(jobTitle = it.ifBlank { null })) }, | |
| 64 | + onValueChange = { | |
| 65 | + onChange(card.copy(jobTitle = it.ifBlank { null }, champsDouteux = card.champsDouteux - "jobTitle")) | |
| 66 | + }, | |
| 56 | 67 | label = stringResource(R.string.scan_champ_poste), |
| 68 | + supportingText = sousTexte("jobTitle"), | |
| 57 | 69 | keyboardOptions = suivant, |
| 58 | 70 | ) |
| 59 | 71 | ChampTexte( |
@@ -80,8 +92,11 @@ fun ContactDraftFields(
| 80 | 92 | ) |
| 81 | 93 | ChampTexte( |
| 82 | 94 | value = card.address.orEmpty(), |
| 83 | - onValueChange = { onChange(card.copy(address = it.ifBlank { null })) }, | |
| 95 | + onValueChange = { | |
| 96 | + onChange(card.copy(address = it.ifBlank { null }, champsDouteux = card.champsDouteux - "address")) | |
| 97 | + }, | |
| 84 | 98 | label = stringResource(R.string.scan_champ_adresse), |
| 99 | + supportingText = sousTexte("address"), | |
| 85 | 100 | keyboardOptions = suivant, |
| 86 | 101 | ) |
| 87 | 102 | ChampTexte( |
M
android/app/src/main/res/values/strings.xml
+1
-0
@@ -27,6 +27,7 @@
| 27 | 27 | <string name="scan_champ_site">Site web</string> |
| 28 | 28 | <string name="scan_champ_adresse">Adresse</string> |
| 29 | 29 | <string name="scan_champ_note">Note</string> |
| 30 | + <string name="scan_champ_a_verifier">À vérifier — lecture incertaine</string> | |
| 30 | 31 | |
| 31 | 32 | <string name="carnet_recherche">Rechercher…</string> |
| 32 | 33 | <string name="carnet_tri_nom">Nom</string> |
A
android/app/src/test/java/fr/ebii/card2vcf/contact/ContactHeuristicParserSpatialTest.kt
+169
-0
@@ -0,0 +1,169 @@
| 1 | +package fr.ebii.card2vcf.contact | |
| 2 | + | |
| 3 | +import fr.ebii.card2vcf.ocr.OcrBox | |
| 4 | +import fr.ebii.card2vcf.ocr.OcrLine | |
| 5 | +import fr.ebii.card2vcf.ocr.OcrResult | |
| 6 | +import kotlin.test.Test | |
| 7 | +import kotlin.test.assertEquals | |
| 8 | +import kotlin.test.assertTrue | |
| 9 | + | |
| 10 | +class ContactHeuristicParserSpatialTest { | |
| 11 | + | |
| 12 | + private fun ligne( | |
| 13 | + text: String, | |
| 14 | + top: Int, | |
| 15 | + height: Int = 30, | |
| 16 | + left: Int = 40, | |
| 17 | + right: Int = 500, | |
| 18 | + confidence: Float = 90f, | |
| 19 | + ) = OcrLine(text, OcrBox(left, top, right, top + height), confidence) | |
| 20 | + | |
| 21 | + private fun ocr( | |
| 22 | + lines: List<OcrLine>, | |
| 23 | + phones: List<String> = emptyList(), | |
| 24 | + emails: List<String> = emptyList(), | |
| 25 | + urls: List<String> = emptyList(), | |
| 26 | + ) = OcrResult( | |
| 27 | + rawText = lines.joinToString("\n") { it.text }, | |
| 28 | + lines = lines.map { it.text }, | |
| 29 | + phones = phones, | |
| 30 | + emails = emails, | |
| 31 | + urls = urls, | |
| 32 | + spatialLines = lines, | |
| 33 | + ) | |
| 34 | + | |
| 35 | + @Test fun adresseMultiLignesAssemblee() { | |
| 36 | + val card = ContactHeuristicParser.parse( | |
| 37 | + ocr( | |
| 38 | + listOf( | |
| 39 | + ligne("Sonia MARTIN", top = 100, height = 40), | |
| 40 | + ligne("561 allée des Noisetiers", top = 400), | |
| 41 | + ligne("69760 Limonest - France", top = 440), | |
| 42 | + ), | |
| 43 | + ), | |
| 44 | + ) | |
| 45 | + assertEquals("561 allée des Noisetiers, 69760 Limonest - France", card.address) | |
| 46 | + } | |
| 47 | + | |
| 48 | + @Test fun nomEnGrosCaracteresSansMajusculesDetecte() { | |
| 49 | + val card = ContactHeuristicParser.parse( | |
| 50 | + ocr( | |
| 51 | + listOf( | |
| 52 | + ligne("sonia martin", top = 100, height = 64), | |
| 53 | + ligne("directrice commerciale", top = 180, height = 28), | |
| 54 | + ), | |
| 55 | + emails = listOf("sonia.martin@voltea.fr"), | |
| 56 | + ), | |
| 57 | + ) | |
| 58 | + assertEquals("Martin", card.lastName) | |
| 59 | + assertEquals("sonia", card.firstName?.lowercase()) | |
| 60 | + assertEquals("directrice commerciale", card.jobTitle) | |
| 61 | + } | |
| 62 | + | |
| 63 | + @Test fun societeDominanteEnMajusculesSepareeDuNom() { | |
| 64 | + val card = ContactHeuristicParser.parse( | |
| 65 | + ocr( | |
| 66 | + listOf( | |
| 67 | + ligne("VOLTEA SOLUTIONS", top = 40, height = 50), | |
| 68 | + ligne("Sonia MARTIN", top = 200, height = 34), | |
| 69 | + ligne("Responsable grands comptes", top = 244, height = 28), | |
| 70 | + ), | |
| 71 | + emails = listOf("sonia.martin@voltea.fr"), | |
| 72 | + ), | |
| 73 | + ) | |
| 74 | + assertEquals("VOLTEA SOLUTIONS", card.company) | |
| 75 | + assertEquals("Sonia Martin", card.fullName) | |
| 76 | + assertEquals("Responsable grands comptes", card.jobTitle) | |
| 77 | + } | |
| 78 | + | |
| 79 | + @Test fun nomToutEnMajusculesResteUnNomPasUneSociete() { | |
| 80 | + // Très courant en France : « SONIA MARTIN » tout en capitales. | |
| 81 | + val card = ContactHeuristicParser.parse( | |
| 82 | + ocr( | |
| 83 | + listOf( | |
| 84 | + ligne("SONIA MARTIN", top = 100, height = 34), | |
| 85 | + ligne("Directrice commerciale", top = 150, height = 28), | |
| 86 | + ), | |
| 87 | + emails = listOf("sonia.martin@voltea.fr"), | |
| 88 | + ), | |
| 89 | + ) | |
| 90 | + assertEquals("Sonia Martin", card.fullName) | |
| 91 | + assertEquals("Martin", card.lastName) | |
| 92 | + assertEquals("Voltea", card.company) // depuis le domaine email, pas le nom | |
| 93 | + } | |
| 94 | + | |
| 95 | + @Test fun deuxColonnesSansMelangeAdresseTelephone() { | |
| 96 | + val card = ContactHeuristicParser.parse( | |
| 97 | + ocr( | |
| 98 | + listOf( | |
| 99 | + ligne("Sonia MARTIN", top = 60, height = 40, left = 40, right = 900), | |
| 100 | + ligne("12 rue des Fleurs", top = 400, left = 40, right = 380), | |
| 101 | + ligne("75011 Paris", top = 440, left = 40, right = 380), | |
| 102 | + ligne("Tél. 04 75 35 12 34", top = 405, left = 600, right = 980), | |
| 103 | + ligne("sonia@voltea.fr", top = 445, left = 600, right = 980), | |
| 104 | + ), | |
| 105 | + phones = listOf("0475351234"), | |
| 106 | + emails = listOf("sonia@voltea.fr"), | |
| 107 | + ), | |
| 108 | + ) | |
| 109 | + assertEquals("12 rue des Fleurs, 75011 Paris", card.address) | |
| 110 | + assertEquals(listOf("0475351234"), card.phones) | |
| 111 | + } | |
| 112 | + | |
| 113 | + @Test fun libelleTelephoneAvecValeurSurLaLigneSuivante() { | |
| 114 | + val card = ContactHeuristicParser.parse( | |
| 115 | + ocr( | |
| 116 | + listOf( | |
| 117 | + ligne("Sonia MARTIN", top = 60, height = 40), | |
| 118 | + ligne("Tél. :", top = 300), | |
| 119 | + ligne("04 75 35 12 34", top = 340), | |
| 120 | + ), | |
| 121 | + ), | |
| 122 | + ) | |
| 123 | + assertTrue(card.phones.contains("0475351234"), "téléphone attendu: ${card.phones}") | |
| 124 | + } | |
| 125 | + | |
| 126 | + @Test fun champsDouteuxMarquesSousLeSeuilDeConfiance() { | |
| 127 | + val card = ContactHeuristicParser.parse( | |
| 128 | + ocr( | |
| 129 | + listOf( | |
| 130 | + ligne("Sonia MARTIN", top = 100, height = 40, confidence = 45f), | |
| 131 | + ligne("561 allée des Noisetiers", top = 400, confidence = 92f), | |
| 132 | + ligne("69760 Limonest - France", top = 440, confidence = 91f), | |
| 133 | + ), | |
| 134 | + ), | |
| 135 | + ) | |
| 136 | + assertTrue("fullName" in card.champsDouteux, "nom à 45% doit être douteux: ${card.champsDouteux}") | |
| 137 | + assertTrue("address" !in card.champsDouteux, "adresse à ~91% doit être sûre: ${card.champsDouteux}") | |
| 138 | + } | |
| 139 | + | |
| 140 | + @Test fun fallbackSansBoxesAucunChampDouteux() { | |
| 141 | + val card = ContactHeuristicParser.parse( | |
| 142 | + OcrResult(rawText = "Sonia MARTIN\nDirectrice", lines = listOf("Sonia MARTIN", "Directrice")), | |
| 143 | + ) | |
| 144 | + assertEquals(emptySet(), card.champsDouteux) | |
| 145 | + } | |
| 146 | + | |
| 147 | + @Test fun cheminSpatialSeulementAvecBoxes() { | |
| 148 | + val textes = listOf( | |
| 149 | + "Sonia MARTIN", | |
| 150 | + "561 allée des Noisetiers", | |
| 151 | + "69760 Limonest - France", | |
| 152 | + ) | |
| 153 | + val avecBoxes = ContactHeuristicParser.parse( | |
| 154 | + ocr( | |
| 155 | + listOf( | |
| 156 | + ligne(textes[0], top = 100, height = 40), | |
| 157 | + ligne(textes[1], top = 400), | |
| 158 | + ligne(textes[2], top = 440), | |
| 159 | + ), | |
| 160 | + ), | |
| 161 | + ) | |
| 162 | + val sansBoxes = ContactHeuristicParser.parse( | |
| 163 | + OcrResult(rawText = textes.joinToString("\n"), lines = textes), | |
| 164 | + ) | |
| 165 | + // Spatial : adresse complète ; legacy : une seule ligne (la plus longue). | |
| 166 | + assertEquals("561 allée des Noisetiers, 69760 Limonest - France", avecBoxes.address) | |
| 167 | + assertEquals("561 allée des Noisetiers", sansBoxes.address) | |
| 168 | + } | |
| 169 | +} |
A
android/app/src/test/java/fr/ebii/card2vcf/ocr/BlockGrouperTest.kt
+96
-0
@@ -0,0 +1,96 @@
| 1 | +package fr.ebii.card2vcf.ocr | |
| 2 | + | |
| 3 | +import kotlin.test.Test | |
| 4 | +import kotlin.test.assertEquals | |
| 5 | +import kotlin.test.assertTrue | |
| 6 | + | |
| 7 | +class BlockGrouperTest { | |
| 8 | + | |
| 9 | + private fun ligne( | |
| 10 | + text: String, | |
| 11 | + top: Int, | |
| 12 | + height: Int = 30, | |
| 13 | + left: Int = 0, | |
| 14 | + right: Int = 300, | |
| 15 | + confidence: Float = 90f, | |
| 16 | + ) = OcrLine(text, OcrBox(left, top, right, top + height), confidence) | |
| 17 | + | |
| 18 | + @Test fun listeVideAucunBloc() { | |
| 19 | + assertEquals(emptyList(), BlockGrouper.group(emptyList())) | |
| 20 | + } | |
| 21 | + | |
| 22 | + @Test fun troisPavesSeparesDonnentTroisBlocs() { | |
| 23 | + val lines = listOf( | |
| 24 | + ligne("Sonia MARTIN", top = 100, height = 40), | |
| 25 | + ligne("Directrice commerciale", top = 150), | |
| 26 | + ligne("561 allée des Noisetiers", top = 400), | |
| 27 | + ligne("69760 Limonest", top = 440), | |
| 28 | + ligne("Tél. 04 75 35 12 34", top = 700), | |
| 29 | + ) | |
| 30 | + val blocs = BlockGrouper.group(lines) | |
| 31 | + assertEquals(3, blocs.size) | |
| 32 | + assertEquals(listOf("Sonia MARTIN", "Directrice commerciale"), blocs[0].lines.map { it.text }) | |
| 33 | + assertEquals(listOf("561 allée des Noisetiers", "69760 Limonest"), blocs[1].lines.map { it.text }) | |
| 34 | + assertEquals(listOf("Tél. 04 75 35 12 34"), blocs[2].lines.map { it.text }) | |
| 35 | + } | |
| 36 | + | |
| 37 | + @Test fun lignesAdresseContiguesGroupees() { | |
| 38 | + val blocs = BlockGrouper.group( | |
| 39 | + listOf( | |
| 40 | + ligne("561 allée des Noisetiers", top = 100), | |
| 41 | + ligne("69760 Limonest - France", top = 140), | |
| 42 | + ), | |
| 43 | + ) | |
| 44 | + assertEquals(1, blocs.size) | |
| 45 | + assertEquals("561 allée des Noisetiers\n69760 Limonest - France", blocs[0].text()) | |
| 46 | + } | |
| 47 | + | |
| 48 | + @Test fun deuxColonnesNonFusionneesMemeSiEntrelaceesVerticalement() { | |
| 49 | + val gauche1 = ligne("561 allée des Noisetiers", top = 100, left = 0, right = 300) | |
| 50 | + val droite1 = ligne("Tél. 04 75 35 12 34", top = 110, left = 600, right = 900) | |
| 51 | + val gauche2 = ligne("69760 Limonest", top = 140, left = 0, right = 300) | |
| 52 | + val droite2 = ligne("contact@voltea.fr", top = 150, left = 600, right = 900) | |
| 53 | + val blocs = BlockGrouper.group(listOf(gauche1, droite1, gauche2, droite2)) | |
| 54 | + assertEquals(2, blocs.size) | |
| 55 | + val textes = blocs.map { b -> b.lines.map { it.text } } | |
| 56 | + assertTrue(textes.contains(listOf("561 allée des Noisetiers", "69760 Limonest")), "colonne gauche intacte: $textes") | |
| 57 | + assertTrue(textes.contains(listOf("Tél. 04 75 35 12 34", "contact@voltea.fr")), "colonne droite intacte: $textes") | |
| 58 | + } | |
| 59 | + | |
| 60 | + @Test fun hauteurMedianeRobusteAUneLigneGeante() { | |
| 61 | + // Logo géant (h=200) : la médiane reste ~30, donc l'écart de 60 px sous le logo coupe. | |
| 62 | + val blocs = BlockGrouper.group( | |
| 63 | + listOf( | |
| 64 | + ligne("VOLTEA", top = 0, height = 200), | |
| 65 | + ligne("Sonia Martin", top = 260), | |
| 66 | + ligne("Directrice", top = 300), | |
| 67 | + ), | |
| 68 | + ) | |
| 69 | + assertEquals(2, blocs.size) | |
| 70 | + assertEquals(listOf("VOLTEA"), blocs[0].lines.map { it.text }) | |
| 71 | + assertEquals(listOf("Sonia Martin", "Directrice"), blocs[1].lines.map { it.text }) | |
| 72 | + } | |
| 73 | + | |
| 74 | + @Test fun blocsOrdonnesDeHautEnBasPuisDeGaucheADroite() { | |
| 75 | + val basGauche = ligne("bas gauche", top = 500, left = 0, right = 200) | |
| 76 | + val hautDroite = ligne("haut droite", top = 100, left = 600, right = 800) | |
| 77 | + val hautGauche = ligne("haut gauche", top = 100, left = 0, right = 200) | |
| 78 | + val blocs = BlockGrouper.group(listOf(basGauche, hautDroite, hautGauche)) | |
| 79 | + assertEquals( | |
| 80 | + listOf("haut gauche", "haut droite", "bas gauche"), | |
| 81 | + blocs.map { it.text() }, | |
| 82 | + ) | |
| 83 | + } | |
| 84 | + | |
| 85 | + @Test fun proprietesDeBloc() { | |
| 86 | + val bloc = OcrBlock( | |
| 87 | + listOf( | |
| 88 | + OcrLine("a", OcrBox(10, 100, 200, 130), 80f), | |
| 89 | + OcrLine("b", OcrBox(20, 140, 300, 170), 60f), | |
| 90 | + ), | |
| 91 | + ) | |
| 92 | + assertEquals(OcrBox(10, 100, 300, 170), bloc.box()) | |
| 93 | + assertEquals(70f, bloc.avgConfidence()) | |
| 94 | + assertEquals("a\nb", bloc.text()) | |
| 95 | + } | |
| 96 | +} |
GitRust