BlockGrouper.kt 40 lignes · 1631 octets
package fr.ebii.card2vcf.ocr

/**
 * Regroupe les lignes OCR en blocs spatiaux : une ligne rejoint le bloc le plus
 * proche qui la chevauche horizontalement (même colonne) si l'écart vertical
 * reste sous [GAP_FACTOR] × hauteur de ligne médiane. La médiane rend le seuil
 * robuste aux lignes géantes (logos). O(n × blocs), n ≈ 10-40 lignes.
 */
object BlockGrouper {
    private const val GAP_FACTOR = 1.8

    fun group(lines: List<OcrLine>): List<OcrBlock> {
        if (lines.isEmpty()) return emptyList()
        val sorted = lines.sortedWith(compareBy({ it.box.top }, { it.box.left }))
        val maxGap = medianHeight(sorted) * GAP_FACTOR

        val blocks = mutableListOf<MutableList<OcrLine>>()
        for (line in sorted) {
            val candidate = blocks
                .filter { block ->
                    val blockBox = block.map { it.box }.reduce(OcrBox::union)
                    blockBox.overlapsHorizontally(line.box) &&
                        line.box.top - blockBox.bottom <= maxGap
                }
                .maxByOrNull { block -> block.maxOf { it.box.bottom } }
            if (candidate != null) candidate += line else blocks += mutableListOf(line)
        }

        return blocks
            .map { OcrBlock(it) }
            .sortedWith(compareBy({ it.box().top }, { it.box().left }))
    }

    private fun medianHeight(lines: List<OcrLine>): Double {
        val heights = lines.map { it.box.height }.sorted()
        val mid = heights.size / 2
        return if (heights.size % 2 == 1) heights[mid].toDouble()
        else (heights[mid - 1] + heights[mid]) / 2.0
    }
}