Skip to content

Commit 6ed5ee7

Browse files
marduc812claude
andcommitted
Fix line-grouping order/mixed-height bugs, add column detection, skip redundant second OCR pass (v1.0.7)
Line grouping was order-dependent and anchored to the first observation's height, so mixed-size text on a baseline could split across lines. Grouping is now order-independent pixel-space with a running-mean midpoint. Same-line items separated by more than 2x glyph height now join with a tab instead of a space, preserving column/label-value structure. The adaptive upscale pass is skipped when the requested scale is small and the first pass is already highly confident, cutting recognition time on typical Retina captures. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 24f0d8c commit 6ed5ee7

3 files changed

Lines changed: 249 additions & 34 deletions

File tree

MiniMe.xcodeproj/project.pbxproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@
427427
"$(inherited)",
428428
"@executable_path/../Frameworks",
429429
);
430-
MARKETING_VERSION = 1.0.6;
430+
MARKETING_VERSION = 1.0.7;
431431
PRODUCT_BUNDLE_IDENTIFIER = com.evaidon.minime;
432432
PRODUCT_NAME = "$(TARGET_NAME)";
433433
SWIFT_EMIT_LOC_STRINGS = YES;
@@ -456,7 +456,7 @@
456456
"$(inherited)",
457457
"@executable_path/../Frameworks",
458458
);
459-
MARKETING_VERSION = 1.0.6;
459+
MARKETING_VERSION = 1.0.7;
460460
PRODUCT_BUNDLE_IDENTIFIER = com.evaidon.minime;
461461
PRODUCT_NAME = "$(TARGET_NAME)";
462462
SWIFT_EMIT_LOC_STRINGS = YES;

MiniMe/Services/OCREngine.swift

Lines changed: 99 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
//
2+
// OCRLineCompositionTests.swift
3+
// MiniMeTests
4+
//
5+
// Pure unit tests for line grouping and joining of recognized text items,
6+
// independent of Vision. Items are in pixel space with a bottom-left origin
7+
// (larger midY = higher on screen), matching what OCREngine feeds in.
8+
//
9+
10+
import Testing
11+
import CoreGraphics
12+
@testable import MiniMe
13+
14+
struct OCRLineCompositionTests {
15+
16+
private func item(
17+
_ text: String,
18+
minX: CGFloat,
19+
maxX: CGFloat,
20+
midY: CGFloat,
21+
height: CGFloat = 20
22+
) -> OCREngine.TextItem {
23+
OCREngine.TextItem(text: text, minX: minX, maxX: maxX, midY: midY, height: height)
24+
}
25+
26+
// MARK: - Line grouping
27+
28+
@Test func ordersLinesTopToBottomRegardlessOfInputOrder() {
29+
// Bottom line delivered first; output must still read top to bottom.
30+
let items = [
31+
item("bottom", minX: 0, maxX: 80, midY: 10),
32+
item("top", minX: 0, maxX: 50, midY: 100),
33+
]
34+
35+
let text = OCREngine.composeLineAwareText(from: items)
36+
37+
#expect(text == "top\nbottom")
38+
}
39+
40+
@Test func groupsMixedHeightItemsOnSameBaseline() {
41+
// A small word followed by a large word sharing a baseline (e.g. mixed
42+
// font sizes in a heading). Small box: y 90-105, large box: y 90-120.
43+
// Grouping anchored to the small item's height alone would split them.
44+
let items = [
45+
item("small", minX: 0, maxX: 60, midY: 97.5, height: 15),
46+
item("BIG", minX: 70, maxX: 130, midY: 105, height: 30),
47+
]
48+
49+
let text = OCREngine.composeLineAwareText(from: items)
50+
51+
#expect(text == "small BIG")
52+
}
53+
54+
@Test func sortsItemsLeftToRightWithinLine() {
55+
let items = [
56+
item("right", minX: 60, maxX: 120, midY: 50),
57+
item("left", minX: 0, maxX: 50, midY: 50),
58+
]
59+
60+
let text = OCREngine.composeLineAwareText(from: items)
61+
62+
#expect(text == "left right")
63+
}
64+
65+
@Test func keepsAdjacentLinesSeparate() {
66+
// Two normally spaced lines (line pitch a bit above line height).
67+
let items = [
68+
item("first", minX: 0, maxX: 60, midY: 100),
69+
item("second", minX: 0, maxX: 70, midY: 76),
70+
]
71+
72+
let text = OCREngine.composeLineAwareText(from: items)
73+
74+
#expect(text == "first\nsecond")
75+
}
76+
77+
// MARK: - Gap-aware joining
78+
79+
@Test func joinsDistantItemsWithTab() {
80+
// Vision usually merges words of a sentence into one observation, so two
81+
// observations far apart on a line are a real gap (columns, label/value).
82+
let items = [
83+
item("Name:", minX: 0, maxX: 100, midY: 50),
84+
item("John", minX: 200, maxX: 260, midY: 50),
85+
]
86+
87+
let text = OCREngine.composeLineAwareText(from: items)
88+
89+
#expect(text == "Name:\tJohn")
90+
}
91+
92+
@Test func joinsNearbyItemsWithSpace() {
93+
let items = [
94+
item("Hello", minX: 0, maxX: 100, midY: 50),
95+
item("world", minX: 108, maxX: 180, midY: 50),
96+
]
97+
98+
let text = OCREngine.composeLineAwareText(from: items)
99+
100+
#expect(text == "Hello world")
101+
}
102+
103+
// MARK: - Second-pass decision
104+
105+
@Test func skipsSecondPassWhenNoUpscaleRequested() {
106+
let run = OCREngine.shouldRunSecondPass(
107+
requestedScale: 1,
108+
firstPassConfidence: 0.2,
109+
options: .default
110+
)
111+
112+
#expect(!run)
113+
}
114+
115+
@Test func skipsSecondPassWhenNearTargetAndConfident() {
116+
// Typical clean Retina capture: glyphs just under target, first pass
117+
// already near-perfect. Re-running recognition would only cost time.
118+
let run = OCREngine.shouldRunSecondPass(
119+
requestedScale: 1.3,
120+
firstPassConfidence: 0.99,
121+
options: .default
122+
)
123+
124+
#expect(!run)
125+
}
126+
127+
@Test func runsSecondPassWhenNearTargetButUnconfident() {
128+
let run = OCREngine.shouldRunSecondPass(
129+
requestedScale: 1.3,
130+
firstPassConfidence: 0.6,
131+
options: .default
132+
)
133+
134+
#expect(run)
135+
}
136+
137+
@Test func runsSecondPassForSmallGlyphsEvenWhenConfident() {
138+
// Tiny text can produce confident-but-wrong partial reads; a large
139+
// requested upscale always earns a second look.
140+
let run = OCREngine.shouldRunSecondPass(
141+
requestedScale: 3,
142+
firstPassConfidence: 0.99,
143+
options: .default
144+
)
145+
146+
#expect(run)
147+
}
148+
}

0 commit comments

Comments
 (0)