Figment.so
Mac hostingWebsite hostingGuidesHow to usePricing

Process OCR Batches with Vision on a Hosted Mac

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.

Choose a Mac plan and define the job

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:

FactorImplication
Input formatPNG, JPEG, TIFF, HEIC load through ImageIO; PDF pages need a PDFKit render step first
Required languagesVision biases toward English by default; set recognitionLanguages explicitly for other scripts
Confidence thresholdDetermines accept/reject split; tune on a representative sample before committing
ScheduleOvernight batch → launchd StartCalendarInterval; watch-folder → QueueDirectories

Inputs and required permissions

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:

  • Read access to the input directory (SFTP in, cloud sync, or a mounted share)
  • Write access to an output directory for .txt results
  • No outbound call to an OCR API; processing is entirely local on the Mac

From 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.

Implement the concrete workflow

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.

Batch acceptance matrix and confidence review plan

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 rangeTypical source materialRecommended action
0.95 – 1.00Machine-printed text, modern scansAccept automatically; spot-check a small random sample
0.80 – 0.94Mixed print and light handwritingAccept; route to a low-priority human review queue
0.50 – 0.79Poor scan quality, faded inkRoute to review queue; flag the original scan
0.00 – 0.49Non-text images, damaged scansReject 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.

Schedule with launchd

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.

Failure and recovery checks

  • Append each successfully written filename to ~/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.
  • The script's non-zero exit lets a wrapper notify you on failure. Post the alert using a webhook secret pulled from your own secret manager — do not place the webhook URL into the plist's environment keys or into interactive shell history.
  • Staleness check: schedule a separate 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.
  • Keep a dated acceptance log (sample size, histogram shape, chosen threshold). Threshold tuning without a record is a frequent source of silent regressions.

Cost worksheet for this workload

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:

  • The virtual Intel macOS plan at $29/month is enough for a steady overnight batch that is the main job on the machine. Twelve months at $29 = $348 per year.
  • The physical i3 Mac at $49/month offers additional CPU headroom for parallel or mixed workloads; twelve months at $49 = $588 per year, a $240 annual uplift over the VM. This plan is request-based, so confirm availability and lead time before planning a cutover.
  • The physical M-series from $95/month is the right choice only if you need Neural Engine acceleration, Xcode 27, or ARM execution; confirm the exact chip, RAM, and disk in your quote before comparing it to the VM rate. The $95 entry is a lower starting price than an owned M-series machine, not evidence of equivalent hardware.

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.

Start or migrate the workload

  1. Request a Figment hosted Mac at /mac-hosting. Choose the virtual Intel macOS plan for file-based Vision OCR; request a physical i3 or M-series plan if you need more CPU, the Neural Engine, or Xcode 27.
  2. Sign in to the VM through the browser console, create ~/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.
  3. Create working directories: mkdir -p ~/ocr-spool ~/ocr-output ~/Library/Logs.
  4. Install the LaunchAgent plist above (with your real user name) and load it with launchctl load.
  5. Drop a test image into ~/ocr-spool and confirm a matching .txt appears in ~/ocr-output.
  6. Collect a representative sample, build a histogram, and tune the confidence threshold with the acceptance matrix above.

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


Subscribe for Figma design and website publishing guides.


Figment.so

Contact

Twitter

Website pricing

Compute index

About

Privacy

Terms