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