BlockGrouper.swift 50 lignes · 2018 octets
import Foundation

/// Groups OCR lines into spatial blocks: a line joins the closest block that
/// overlaps it horizontally (same column) when the vertical gap stays under
/// `gapFactor` × median line height. The median keeps the threshold robust to
/// giant lines (logos). O(n × blocks), n ≈ 10-40 lines.
enum BlockGrouper {
    private static let gapFactor = 1.8

    static func group(_ lines: [OcrLine]) -> [OcrBlock] {
        if lines.isEmpty { return [] }
        let sorted = lines.sorted {
            ($0.box.top, $0.box.left) < ($1.box.top, $1.box.left)
        }
        let maxGap = medianHeight(sorted) * gapFactor

        var blocks: [[OcrLine]] = []
        for line in sorted {
            // Kotlin maxByOrNull: first block with the lowest bottom edge.
            var candidateIndex: Int? = nil
            var candidateBottom = Int.min
            for (index, block) in blocks.enumerated() {
                let blockBox = block.map(\.box).reduce(block[0].box) { $0.union($1) }
                guard blockBox.overlapsHorizontally(line.box),
                      Double(line.box.top - blockBox.bottom) <= maxGap else { continue }
                let bottom = block.map(\.box.bottom).max() ?? Int.min
                if bottom > candidateBottom {
                    candidateBottom = bottom
                    candidateIndex = index
                }
            }
            if let candidateIndex {
                blocks[candidateIndex].append(line)
            } else {
                blocks.append([line])
            }
        }

        return blocks
            .map { OcrBlock(lines: $0) }
            .sorted { ($0.box().top, $0.box().left) < ($1.box().top, $1.box().left) }
    }

    private static func medianHeight(_ lines: [OcrLine]) -> Double {
        let heights = lines.map(\.box.height).sorted()
        let mid = heights.count / 2
        if heights.count % 2 == 1 { return Double(heights[mid]) }
        return Double(heights[mid - 1] + heights[mid]) / 2.0
    }
}