OCR in Swift with the Vision Framework

Cookbook Index does one thing that has to work: you point your phone at the index page of a cookbook, and it turns the page into a searchable list of recipes and page numbers. All of that runs on Apple's Vision framework. It's on the device, free, needs no API key, and is very good at printed text.

This post covers what I learned getting from "Vision can read text" to "Vision can read a cookbook index": the basic request, camera scanning, turning lines of text into structured data, and the gotchas.

The basic request

Recognising text in an image takes a few lines:

import Vision

func recogniseText(in image: CGImage) throws -> [VNRecognizedTextObservation] {
    let request = VNRecognizeTextRequest()
    request.recognitionLevel = .accurate
    request.usesLanguageCorrection = true
    request.recognitionLanguages = ["en-GB"]

    let handler = VNImageRequestHandler(cgImage: image, orientation: .up)
    try handler.perform([request])
    return request.results ?? []
}

Each observation is one line of text. It holds several candidate readings, best first, and a bounding box:

for observation in observations {
    guard let best = observation.topCandidates(1).first else { continue }
    print(best.string, best.confidence, observation.boundingBox)
}

A few things that matter more than they look:

On iOS 18 and later there's also a Swift-native version of the API, RecognizeTextRequest, with async/await. The ideas are the same.

Scanning from the camera

You can build live scanning yourself with AVCaptureSession, feeding frames into Vision. If you do:

Before building that, look at VisionKit's DataScannerViewController (iOS 16+). It gives you a live camera view that recognises text and barcodes, with highlights and tap-to-select, in a few dozen lines. For many apps it's all you need.

For an index page, I've found it's better to take a still photo and run the accurate recogniser on it. The page doesn't move, and one sharp, high-resolution image beats a stream of slightly blurry frames.

Turning lines into recipes

Vision gives you lines of text. A cookbook index needs entries: a recipe name and one or more page numbers. Most index lines look like one of these:

Apple and blackberry crumble 142
Apple and blackberry crumble, 142
Apple and blackberry crumble ......... 142
Lamb tagine 88, 214
Lemon tart 156–7

A regular expression that takes everything up to the trailing numbers covers most of them:

let pattern = /^(?<name>.+?)[\s,.·…]+(?<pages>\d{1,4}(?:\s*[,–-]\s*\d{1,4})*)$/

if let match = line.firstMatch(of: pattern) {
    let name = String(match.name).trimmingCharacters(in: .whitespaces)
    let pages = match.pages
        .split(whereSeparator: { ",–-".contains($0) })
        .compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
}

The lines that don't match are interesting too:

Multi-column pages

Indexes are usually two or three columns, and Vision doesn't promise to return lines in reading order. Sorting by vertical position alone interleaves the columns, mixing "Apple crumble" with "Lamb tagine" from the next column.

The fix is to group observations into columns by their horizontal position first, then read each column top to bottom:

// Cluster by left edge: lines that start near each other share a column.
let sorted = observations.sorted { $0.boundingBox.minX < $1.boundingBox.minX }
var columns: [[VNRecognizedTextObservation]] = []
for obs in sorted {
    if let last = columns.last?.first,
       abs(obs.boundingBox.minX - last.boundingBox.minX) < 0.08 {
        columns[columns.count - 1].append(obs)
    } else {
        columns.append([obs])
    }
}
// Vision's y origin is the bottom, so read each column by descending maxY.
let ordered = columns.flatMap { $0.sorted { $0.boundingBox.maxY > $1.boundingBox.maxY } }

The 0.08 threshold is a fraction of the page width. Tune it on real books, because some indexes indent sub-entries enough to look like a new column.

ISBN barcodes

The fastest way to identify a cookbook is the barcode on the back. An ISBN-13 is an EAN-13 barcode starting with 978 or 979, and Vision reads it with a separate request:

let barcodes = VNDetectBarcodesRequest()
barcodes.symbologies = [.ean13]
try handler.perform([barcodes])

let isbn = barcodes.results?
    .compactMap(\.payloadStringValue)
    .first { $0.hasPrefix("978") || $0.hasPrefix("979") }

Look up the ISBN against a book database, and you have the title, author and cover without the user typing anything.

Gotchas

What I'd tell anyone building on Vision