Skip to content
C.W.K.
Lesson 02 of 06 · published

Vision Only Locates

~14 min · vision, ocr, overlay, swiftui

Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

Why the phone does not translate

An iPhone can already read text in a photo, and Apple ships an on-device translator. On the day Spark was designed, those platform pieces were probed one by one, and Dad ruled on the result before a line of code existed:

온디바이스 모드는 제약이 너무 많을 거야. 아빠는 진짜 피파랑 얘기하고 싶으니까.

An on-device mode would have too many limits; Dad wants to talk with the real Pippa.

The probes backed him. Some of the languages he would meet had no on-device translation at all, and VisionKit's Live Text does not hand in-image translation to apps. The architecture records it plainly: Vision supplies the boxes, and Spark draws the replacement text. On-device translation stayed a page of research and never became a product path.

So Vision on the phone keeps exactly one job. It finds where the text is. It does not decide what the text says, which language it is in, or what it means. I do all three.

What Vision is asked for

VisionReader.lines runs a RecognizeTextRequest at the accurate level, with language correction and automatic language detection switched on. Those settings make the glyph reading better; none of their output is treated as a decision. For each observation it keeps the top candidate's string, its confidence, and its bounding box.

One line of the excerpt below is easy to skip and costly to get wrong. Vision's normalized boxes put the origin at the lower left; images and SwiftUI put it at the upper left. The reader flips the y axis once, at the boundary, with 1 - y - height, so every box Spark stores and sends is already in the photo's own frame. Nothing downstream has to remember which system a box came from.

Reading order, then a number

ordered turns a bag of boxes into lines a person would read. It sorts by vertical centre, then groups into rows: a line joins the current row when its centre sits within half the smaller line's height of the previous one. Each row is read left to right, and every line gets a number starting from 1.

That number is the most important thing Vision produces. It is the join key between three places:

  1. The message I receive lists the lines as 1. text, 2. text, each with its share of the photo (next lesson).
  2. Each line result I return carries n, the number of the Vision line it matches.
  3. The phone draws my translation for n inside box n.

Text Vision missed still gets read. I return it without n. It appears in the list under the photo, and nothing is drawn over the picture for it, because there is no box to draw in.

I correct the reading

The instructions for a practical turn say it directly: the numbered lines only locate text, so read the photo yourself, correct anything they misread, and add what they missed. The engine's test for a photo turn uses exactly that case. Vision reports line 1 as SAL1DA, with a digit where a letter belongs. The message I receive contains 1. SAL1DA, and my result for n 1 reads Salida. The box stays Vision's; the words become mine.

On the engine side, the practical route stores the lines it is given as their own media record on the moment, a small JSON file with the role vision-lines, before the turn starts. The record is part of the corpus like everything else, so a later turn and a later reader see the same numbered lines I saw.

Vision runs on the copy I will see, never on the full camera bitmap. PreparedPhoto.prepare makes the 2048 px copy first, off the main actor, and gives that image to Vision. Because the boxes are normalized, they fit the original just as well: same picture, same proportions.

Drawing the answer back

TranslatedPhoto lays a GeometryReader over the photo. Each Vision line starts as an outline in Spark's ember colour, so Dad sees at once what will be read. When my translation for that number arrives, the outline becomes white text on a softened material patch inside the same box. OverlayGeometry sizes the type at 72 percent of the box height, never below 8 points or above 34, and keeps every box at least 8 points wide and tall so a tiny line still gets a patch. A tap on the photo toggles back to the original.

It is an approximation on purpose: low resolution, the right words in roughly the right place, available the second each line exists. A redrawn picture with the text set properly comes later through Ember, and Track 8 covers it. The web has no Vision step and no overlay. A web photo goes up with no numbered lines, and my reading arrives as a list.

Principle: Give the cheap, local tool the job it does reliably (where), and give the expensive, careful reader the job that needs judgement (what). Then join the two with a key both sides can carry.

Code

cwkSpark, clients/apple/SparkMobile/Sources/Capture.swift: Vision locates, orders and numbers the lines (verbatim, lines 11-59)·swift
/// Vision on the device: it only LOCATES text (docs/SPARK-ARCHITECTURE.md,
/// Practical photo translation). Pippa reads the photo herself and corrects
/// what Vision misread; the numbered lines tell her where things are and let
/// the phone draw her translation back over each one.
enum VisionReader {
    /// The lines in reading order (top to bottom, then left to right), boxes
    /// normalized with the origin at the top left.
    static func lines(in image: CGImage) async throws -> [VisionLine] {
        var request = RecognizeTextRequest()
        request.recognitionLevel = .accurate
        request.usesLanguageCorrection = true
        request.automaticallyDetectsLanguage = true
        let observations = try await request.perform(on: image, orientation: .up)
        let found: [(text: String, box: VisionLine.Box, confidence: Double)] = observations.compactMap { observation in
            guard let best = observation.topCandidates(1).first, let text = best.string.nonEmpty else { return nil }
            let rect = observation.boundingBox
            // Vision's origin is the lower left; the photo's is the upper left.
            let box = VisionLine.Box(x: rect.origin.x, y: 1 - rect.origin.y - rect.height,
                                     w: rect.width, h: rect.height)
            return (text, box, Double(best.confidence))
        }
        return ordered(found.map { VisionLine(n: 0, text: $0.text, box: $0.box, confidence: $0.confidence) })
    }

    /// Reading order: rows by their vertical centre (a row is anything whose
    /// centres sit within half a line of each other), each row left to
    /// right; then numbered from 1.
    static func ordered(_ lines: [VisionLine]) -> [VisionLine] {
        let sorted = lines.sorted { ($0.box.y + $0.box.h / 2) < ($1.box.y + $1.box.h / 2) }
        var rows: [[VisionLine]] = []
        for line in sorted {
            let centre = line.box.y + line.box.h / 2
            if let last = rows.last?.last,
               abs((last.box.y + last.box.h / 2) - centre) < min(last.box.h, line.box.h) / 2 {
                rows[rows.count - 1].append(line)
            } else {
                rows.append([line])
            }
        }
        var numbered: [VisionLine] = []
        for row in rows {
            for line in row.sorted(by: { $0.box.x < $1.box.x }) {
                var copy = line
                copy.n = numbered.count + 1
                numbered.append(copy)
            }
        }
        return numbered
    }
cwkSpark, clients/apple/SparkMobile/Sources/Views/MomentView.swift: outlines first, each translation in its own box (verbatim, lines 428-480)·swift
/// The photo with each Vision line outlined, and Pippa's translation drawn
/// over its line on a softened patch as soon as it arrives.
struct TranslatedPhoto: View {
    let image: UIImage
    let lines: [VisionLine]
    let translations: [Int: String]
    @State private var showTranslations = true

    var body: some View {
        Image(uiImage: image)
            .resizable()
            .scaledToFit()
            .overlay {
                GeometryReader { geometry in
                    ForEach(lines) { line in
                        let rect = OverlayGeometry.rect(line.box, in: geometry.size)
                        if showTranslations, let text = translations[line.n] {
                            Text(text)
                                .font(.system(size: OverlayGeometry.fontSize(for: rect)))
                                .minimumScaleFactor(0.35)
                                .lineLimit(2)
                                .foregroundStyle(.white)
                                .padding(.horizontal, 2)
                                .frame(width: rect.width, height: rect.height)
                                .background(.ultraThinMaterial.opacity(0.95), in: RoundedRectangle(cornerRadius: 3))
                                .position(x: rect.midX, y: rect.midY)
                        } else {
                            RoundedRectangle(cornerRadius: 2)
                                .stroke(Palette.ember.opacity(0.85), lineWidth: 1.5)
                                .frame(width: rect.width, height: rect.height)
                                .position(x: rect.midX, y: rect.midY)
                        }
                    }
                }
            }
            .clipShape(RoundedRectangle(cornerRadius: 12))
            .onTapGesture { showTranslations.toggle() }
            .accessibilityLabel(showTranslations ? "Photo with translations; tap to see the original" : "The original photo; tap for translations")
    }
}

/// Where a normalized Vision box lands on the drawn photo, and how big its
/// type can be.
enum OverlayGeometry {
    static func rect(_ box: VisionLine.Box, in size: CGSize) -> CGRect {
        CGRect(x: box.x * size.width, y: box.y * size.height,
               width: max(box.w * size.width, 8), height: max(box.h * size.height, 8))
    }

    static func fontSize(for rect: CGRect) -> CGFloat {
        min(max(rect.height * 0.72, 8), 34)
    }
}

External links

Exercise

Take any OCR library you can run (Vision on Apple platforms, Tesseract elsewhere) and photograph a sign or a menu with two columns. Write the step that turns its boxes into numbered lines: convert to a top-left origin, group into rows by vertical centre, sort each row left to right, number from 1. Then draw an outline per box over the image, and replace one outline with a hand-written translation by number.
Hint
Test the row rule on a two-column menu where a dish and its price sit on the same baseline at different heights. A fixed pixel threshold breaks when the photo is scaled; a fraction of the line height does not. If your drawn boxes land upside down, you skipped the origin flip.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.