@@ -36,6 +36,13 @@ struct OCROptions {
3636 /// Upper bound on the longest side of the image handed to a recognition pass,
3737 /// so adaptive upscaling of a big selection can't blow up memory.
3838 var maxProcessedDimension : CGFloat = 4096
39+ /// First-pass confidence at/above which a near-target upscale is skipped,
40+ /// saving a full second recognition on clean captures.
41+ var secondPassSkipConfidence : Double = 0.98
42+ /// Requested upscale at/below which a confident first pass is trusted.
43+ /// Larger upscales mean genuinely small text, which can produce
44+ /// confident-but-partial reads, so those always get a second pass.
45+ var secondPassSkipMaxScale : CGFloat = 1.5
3946
4047 static let `default` = OCROptions ( )
4148
@@ -79,7 +86,11 @@ struct OCREngine {
7986 maxScale: options. maxUpscale
8087 )
8188 let scale = boundedScale ( requestedScale, for: base, maxDimension: options. maxProcessedDimension)
82- if scale > 1 {
89+ if Self . shouldRunSecondPass (
90+ requestedScale: scale,
91+ firstPassConfidence: first. weightedConfidence,
92+ options: options
93+ ) {
8394 let upscaled = OCRImageProcessor . scaled ( base, by: scale)
8495 if let second = performRecognition ( on: upscaled, options: options) ,
8596 second. weightedConfidence >= first. weightedConfidence {
@@ -119,7 +130,10 @@ struct OCREngine {
119130 guard let observations = request. results as? [ VNRecognizedTextObservation ] else { return }
120131
121132 let text = options. lineAware
122- ? Self . lineAwareText ( from: observations)
133+ ? Self . lineAwareText (
134+ from: observations,
135+ imageSize: CGSize ( width: image. width, height: image. height)
136+ )
123137 : Self . columnText ( from: observations)
124138
125139 result = RecognitionResult (
@@ -192,49 +206,102 @@ struct OCREngine {
192206
193207 // MARK: - Observation ordering
194208
195- private struct TextItem {
209+ struct TextItem {
196210 let text : String
197211 let minX : CGFloat
212+ let maxX : CGFloat
198213 let midY : CGFloat
199214 let height : CGFloat
200215 }
201216
202- /// Groups observations into visual lines and reads left-to-right, top-to-bottom.
203- private static func lineAwareText ( from observations : [ VNRecognizedTextObservation ] ) -> String {
204- let items : [ TextItem ] = observations . compactMap { observation in
205- guard let text = observation . topCandidates ( 1 ) . first ? . string else { return nil }
206- let box = observation . boundingBox
207- return TextItem (
208- text : text ,
209- minX : box . minX ,
210- midY : ( box . minY + box . maxY ) / 2 ,
211- height : box . maxY - box . minY
212- )
213- }
214-
215- var lines : [ ( items : [ ( text : String , minX : CGFloat ) ] , midY: CGFloat , height : CGFloat ) ] = [ ]
216- for item in items {
217- var foundLineIndex : Int ?
217+ /// Horizontal gap between same-line items, relative to glyph height, beyond
218+ /// which they are treated as separate columns and joined with a tab.
219+ private static let columnGapFactor : CGFloat = 2
220+
221+ /// Groups pixel-space items into visual lines and joins them into text.
222+ /// Independent of input order: items are considered top-to-bottom, each one
223+ /// joining the vertically closest line whose distance is under half the
224+ /// taller of the two heights — so mixed font sizes on one baseline still
225+ /// group — or starting a new line. Lines read top-to-bottom, items within a
226+ /// line left-to-right.
227+ static func composeLineAwareText ( from items : [ TextItem ] ) -> String {
228+ var lines : [ ( items : [ TextItem ] , midYSum : CGFloat , maxHeight : CGFloat ) ] = [ ]
229+
230+ for item in items . sorted ( by : { $0 . midY > $1 . midY } ) {
231+ var bestIndex : Int ?
232+ var bestDistance = CGFloat . greatestFiniteMagnitude
218233 for (index, line) in lines. enumerated ( ) {
219- if abs ( item. midY - line. midY) < line. height * 0.5 {
220- foundLineIndex = index
221- break
234+ let meanMidY = line. midYSum / CGFloat( line. items. count)
235+ let distance = abs ( item. midY - meanMidY)
236+ if distance < 0.5 * max( line. maxHeight, item. height) , distance < bestDistance {
237+ bestIndex = index
238+ bestDistance = distance
222239 }
223240 }
224- if let index = foundLineIndex {
225- lines [ index] . items. append ( ( item. text, item. minX) )
241+ if let index = bestIndex {
242+ lines [ index] . items. append ( item)
243+ lines [ index] . midYSum += item. midY
244+ lines [ index] . maxHeight = max ( lines [ index] . maxHeight, item. height)
226245 } else {
227- lines. append ( ( [ ( item. text, item. minX) ] , item. midY, item. height) )
246+ lines. append ( ( [ item] , item. midY, item. height) )
247+ }
248+ }
249+
250+ let sortedLines = lines. sorted {
251+ $0. midYSum / CGFloat( $0. items. count) > $1. midYSum / CGFloat( $1. items. count)
252+ }
253+ return sortedLines. map { joinLine ( $0. items) } . joined ( separator: " \n " )
254+ }
255+
256+ /// Joins one visual line left-to-right. Vision usually merges the words of a
257+ /// sentence into a single observation, so a large gap between two
258+ /// observations is a real one (columns, label/value pairs) and becomes a tab.
259+ private static func joinLine( _ items: [ TextItem ] ) -> String {
260+ var result = " "
261+ var previous : TextItem ?
262+ for item in items. sorted ( by: { $0. minX < $1. minX } ) {
263+ if let previous {
264+ let gap = item. minX - previous. maxX
265+ let glyphHeight = max ( previous. height, item. height)
266+ result += gap > glyphHeight * columnGapFactor ? " \t " : " "
228267 }
268+ result += item. text
269+ previous = item
229270 }
271+ return result
272+ }
230273
231- // Vision uses a bottom-left origin, so larger midY means higher on screen.
232- let sortedLines = lines. sorted { $0. midY > $1. midY }
233- return sortedLines. map { line in
234- line. items. sorted { $0. minX < $1. minX }
235- . map { $0. text }
236- . joined ( separator: " " )
237- } . joined ( separator: " \n " )
274+ /// Decides whether the adaptive upscale pass is worth a second recognition.
275+ static func shouldRunSecondPass(
276+ requestedScale: CGFloat ,
277+ firstPassConfidence: Double ,
278+ options: OCROptions
279+ ) -> Bool {
280+ guard requestedScale > 1 else { return false }
281+ let nearTarget = requestedScale <= options. secondPassSkipMaxScale
282+ let confident = firstPassConfidence >= options. secondPassSkipConfidence
283+ return !( nearTarget && confident)
284+ }
285+
286+ /// Maps observations from Vision's normalized bottom-left coordinates into
287+ /// pixel space (so horizontal gaps and glyph heights are comparable) and
288+ /// composes line-aware text.
289+ private static func lineAwareText(
290+ from observations: [ VNRecognizedTextObservation ] ,
291+ imageSize: CGSize
292+ ) -> String {
293+ let items : [ TextItem ] = observations. compactMap { observation in
294+ guard let text = observation. topCandidates ( 1 ) . first? . string else { return nil }
295+ let box = observation. boundingBox
296+ return TextItem (
297+ text: text,
298+ minX: box. minX * imageSize. width,
299+ maxX: box. maxX * imageSize. width,
300+ midY: ( box. minY + box. maxY) / 2 * imageSize. height,
301+ height: ( box. maxY - box. minY) * imageSize. height
302+ )
303+ }
304+ return composeLineAwareText ( from: items)
238305 }
239306
240307 /// Raw Vision ordering, one observation per line.
0 commit comments