Retry OCR on upscaled images when nothing is read

Tiny UI text (around 7pt) reads as nothing at native size — verified
locally. When a pass returns fewer than three characters, OCR retries at
2x and 3x and keeps the fullest result. The smoke test covers a tiny-text
case so the fallback stays working.

💘 Generated with Crush

Assisted-by: Crush:deepseek-v4-flash-0731
This commit is contained in:
2026-09-30 11:44:26 +08:00
parent 2e971d5f8d
commit 04b84e188a
3 changed files with 73 additions and 36 deletions
+58 -26
View File
@@ -8,34 +8,66 @@ final class OCRService {
func recognize(_ cgImage: CGImage, completion: @escaping (String) -> Void) {
queue.async {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"]
// Without this, Vision defaults to the first listed language and
// renders CJK as Latin garbage (verified: 你好世界 -> "177WR").
if #available(macOS 13.0, *) {
request.automaticallyDetectsLanguage = true
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
completion("")
let text = Self.perform(cgImage)
// Tiny text (small UI labels) often reads as nothing at native
// size; retry upscaled and keep whichever pass read more.
guard text.count < 3 else {
completion(text)
return
}
let observations = request.results ?? []
let lines = observations
.sorted { a, b in
let ay = a.boundingBox.midY
let by = b.boundingBox.midY
if abs(ay - by) > 0.02 { return ay > by }
return a.boundingBox.minX < b.boundingBox.minX
}
.compactMap { $0.topCandidates(1).first?.string }
completion(lines.joined(separator: "\n"))
var best = text
for factor in [CGFloat(2), CGFloat(3)] {
guard let upscaled = Self.upscaled(cgImage, factor: factor) else { continue }
let candidate = Self.perform(upscaled)
if candidate.count > best.count { best = candidate }
if best.count >= 3 { break }
}
completion(best)
}
}
private static func perform(_ cgImage: CGImage) -> String {
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.recognitionLanguages = ["en-US", "zh-Hans", "zh-Hant", "ja-JP", "ko-KR"]
// Without this, Vision defaults to the first listed language and
// renders CJK as Latin garbage (verified: 你好世界 -> "177WR").
if #available(macOS 13.0, *) {
request.automaticallyDetectsLanguage = true
}
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
do {
try handler.perform([request])
} catch {
return ""
}
let observations = request.results ?? []
let lines = observations
.sorted { a, b in
let ay = a.boundingBox.midY
let by = b.boundingBox.midY
if abs(ay - by) > 0.02 { return ay > by }
return a.boundingBox.minX < b.boundingBox.minX
}
.compactMap { $0.topCandidates(1).first?.string }
return lines.joined(separator: "\n")
}
private static func upscaled(_ cgImage: CGImage, factor: CGFloat) -> CGImage? {
let width = Int(CGFloat(cgImage.width) * factor)
let height = Int(CGFloat(cgImage.height) * factor)
guard width > 0, height > 0, width <= 20000, height <= 20000 else { return nil }
guard let ctx = CGContext(
data: nil, width: width, height: height,
bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
) else { return nil }
ctx.interpolationQuality = .high
ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height)))
return ctx.makeImage()
}
}