
Apple's Vision framework recognizes text entirely on-device through VNRecognizeTextRequest, and a hosted Mac gives you a persistent macOS environment to run those batches on a schedule without shipping page images to a third-party API. For file-based OCR that does not require Apple silicon or the Neural Engine, start on the Figment virtual Intel macOS plan at $29/month (2 vCPU, 8 GiB RAM, 80 GiB disk, no GPU and no audio). It runs macOS and the Intel-compatible toolchain: Apple's porting guide says to use Xcode 26 or earlier if you need to run Xcode on an Intel-based processor. For Neural Engine acceleration, heavier concurrency, or Xcode 27, request a physical M-series Mac from $95/month and confirm the exact chip, RAM, and disk in the quote.
Vision's accurate text-recognition path runs a neural network, and on the $29 VM it uses CPU only — the virtual Intel environment has no GPU and no Neural Engine. Two vCPU is the ceiling, so size the batch accordingly. If you saturate CPU on real input (watch top or Activity Monitor), move to the physical i3 Mac at $49/month for additional CPU headroom, or to a physical M-series from $95/month where the Neural Engine can accelerate Vision requests. Physical plans are request-based; do not assume instant availability, and confirm the chip, RAM, disk and lead time in writing before planning a cutover. Figment does not publish an OCR throughput number — benchmark on your own representative sample.
Decide before you provision:
| Factor | Implication |
|---|---|
| Input format | PNG, JPEG, TIFF, HEIC load through ImageIO; PDF pages need a PDFKit render step first |
| Required languages | Vision biases toward English by default; set recognitionLanguages explicitly for other scripts |
| Confidence threshold | Determines accept/reject split; tune on a representative sample before committing |
| Schedule | Overnight batch → launchd StartCalendarInterval; watch-folder → QueueDirectories |
VNRecognizeTextRequest works from a CGImage. File-based OCR of images you own does not require Accessibility or Screen Recording consent; the script reads image files directly through ImageIO. What it needs:
.txt resultsFrom Apple's documentation: to recognize traditional and simplified Chinese, specify zh-Hant and zh-Hans as the first elements in recognitionLanguages; English is the only other language you can pair with Chinese, and language correction is not available for Chinese. For other language sets, call VNRecognizeTextRequest.supportedRecognitionLanguages(for:revision:) and confirm what your installed macOS version actually supports.
Save this as ocr_batch.swift. It is an unexecuted educational example for you to adapt to your own project — not a benchmark result, and not evidence of throughput.
#!/usr/bin/env swift
// ocr_batch.swift — batch image-to-text using Vision VNRecognizeTextRequest
// Usage: ./ocr_batch.swift <input-dir> <output-dir> [min-confidence 0.0-1.0]
import Foundation
import CoreGraphics
import ImageIO
import Vision
let args = CommandLine.arguments
guard args.count >= 3 else {
FileHandle.standardError.write(Data("Usage: ocr_batch <input-dir> <output-dir> [min-confidence]\n".utf8))
exit(2)
}
let inputDir = URL(fileURLWithPath: args[1])
let outputDir = URL(fileURLWithPath: args[2])
let minConf = Double(args.count > 3 ? args[3] : "0.0") ?? 0.0
let fm = FileManager.default
var accepted = 0, skipped = 0, errors = 0
do {
try fm.createDirectory(at: outputDir, withIntermediateDirectories: true)
} catch {
FileHandle.standardError.write(Data("OUTPUT_DIR_ERROR: \(error)\n".utf8))
exit(3)
}
let exts: Set<String> = ["png","jpg","jpeg","tiff","tif","heic"]
let files: [URL]
do {
files = try fm.contentsOfDirectory(at: inputDir, includingPropertiesForKeys: nil)
.filter { exts.contains($0.pathExtension.lowercased()) }
.sorted { $0.lastPathComponent < $1.lastPathComponent }
} catch {
FileHandle.standardError.write(Data("INPUT_DIR_ERROR: \(error)\n".utf8))
exit(4)
}
for file in files {
guard let src = CGImageSourceCreateWithURL(file as CFURL, nil),
let img = CGImageSourceCreateImageAtIndex(src, 0, nil) else {
FileHandle.standardError.write(Data("LOAD_ERROR: \(file.lastPathComponent)\n".utf8))
errors += 1
continue
}
let request = VNRecognizeTextRequest()
// The accurate path is the default; set it explicitly to document intent.
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
// Set request.recognitionLanguages here if you need non-English input.
do {
try VNImageRequestHandler(cgImage: img).perform([request])
} catch {
FileHandle.standardError.write(Data("VISION_ERROR: \(file.lastPathComponent): \(error)\n".utf8))
errors += 1
continue
}
let observations = (request.results as? [VNRecognizedTextObservation]) ?? []
let lines: [String] = observations.compactMap { obs in
guard let top = obs.topCandidates(1).first,
Double(top.confidence) >= minConf else { return nil }
return top.string
}
if lines.isEmpty { skipped += 1; continue }
let outURL = outputDir.appendingPathComponent(
file.deletingPathExtension().lastPathComponent + ".txt")
do {
try lines.joined(separator: "\n").write(to: outURL, atomically: true, encoding: .utf8)
accepted += 1
} catch {
FileHandle.standardError.write(Data("WRITE_ERROR: \(outURL.lastPathComponent): \(error)\n".utf8))
errors += 1
}
}
print("accepted=\(accepted) skipped=\(skipped) errors=\(errors)")
exit(errors > 0 ? 1 : 0)
Run a small manual test before scheduling, using your own paths:
chmod +x ~/scripts/ocr_batch.swift
~/scripts/ocr_batch.swift ~/test-images ~/test-output 0.80
The script exits non-zero when any file errored, so a scheduler, CI step, or wrapper can detect partial failure instead of silently accepting broken output.
Run the first pass with min-confidence 0.0 to collect the full distribution of top-candidate confidence values, then apply this matrix to pick a production threshold:
| Confidence range | Typical source material | Recommended action |
|---|---|---|
| 0.95 – 1.00 | Machine-printed text, modern scans | Accept automatically; spot-check a small random sample |
| 0.80 – 0.94 | Mixed print and light handwriting | Accept; route to a low-priority human review queue |
| 0.50 – 0.79 | Poor scan quality, faded ink | Route to review queue; flag the original scan |
| 0.00 – 0.49 | Non-text images, damaged scans | Reject to a rejected/ subfolder; alert the operator |
Before committing to a threshold, build a histogram from a representative sample from your own pipeline — clean office scans and archival scans behave very differently. Record the chosen threshold, sample size, and date in a short acceptance note so later changes are auditable.
Use QueueDirectories to process a spool folder whenever files arrive, or StartCalendarInterval for a nightly run — both keys are documented in Apple's Creating Launchd Jobs reference. QueueDirectories starts your job while the listed directories are non-empty and keeps it running until they drain.
Save this plist to ~/Library/LaunchAgents/com.example.ocr-batch.plist, replacing agent with your actual account name:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key><string>com.example.ocr-batch</string>
<key>ProgramArguments</key>
<array>
<string>/Users/agent/scripts/ocr_batch.swift</string>
<string>/Users/agent/ocr-spool</string>
<string>/Users/agent/ocr-output</string>
<string>0.80</string>
</array>
<key>QueueDirectories</key>
<array><string>/Users/agent/ocr-spool</string></array>
<key>StandardOutPath</key><string>/Users/agent/Library/Logs/ocr_batch.out.log</string>
<key>StandardErrorPath</key><string>/Users/agent/Library/Logs/ocr_batch.err.log</string>
</dict>
</plist>
Load it with launchctl load ~/Library/LaunchAgents/com.example.ocr-batch.plist. Because paths are LaunchAgent-scoped and user-owned, the job does not require root and will not touch other users' files.
~/ocr-output/processed.txt and skip listed names on restart, so a crash in the middle of a batch does not reprocess completed work on the next launch.StartCalendarInterval job that alerts if ocr-spool keeps growing while ocr-output has not changed in 30 minutes. That catches a stuck launchd job that launchctl load alone will not surface.Rough example for a department scanning around 2,000 A4 pages per month at 150 dpi — your numbers will depend on image size, language mix, and whether language correction is enabled:
Disk usage depends on your input resolution and retention policy. A 150 dpi grayscale A4 scan is often a few hundred KB as PNG; a color 300 dpi scan can be several megabytes. Estimate your own per-page size, multiply by expected retention, and sync completed output off-host (S3, Backblaze B2, or your own object store) before the 80 GiB disk fills.
~/scripts/, and paste ocr_batch.swift. Inbound SSH to the VM is not currently offered; outbound connections to your Git host, Slack, or object storage work normally, and CI agents can register outbound to your own service.mkdir -p ~/ocr-spool ~/ocr-output ~/Library/Logs.launchctl load.~/ocr-spool and confirm a matching .txt appears in ~/ocr-output.Configure your plan and start →
Related guides: Run a Mac API agent runtime · Self-hosted GitHub Actions runner on Mac · Migrate your Mac agent workload