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