Skip to content
C.W.K.
Lesson 01 of 04 · published

A Scanner That Keeps Track of Text

~14 min · visionkit, live-camera, batching, ios

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

Point, and the words change

The live camera is the one mode in Spark where Dad does not press anything. He opens it from the capture bar, points the phone at a wall of signs, and my Korean appears over the text while he keeps walking. Dad asked for exactly this on the design day, and he named Microsoft Translator's camera mode as the feel he wanted. This track is how Spark got there without breaking the rule that set its shape: every translation is mine, never a machine layer.

Spark's live camera on an iPhone pointed at a printed drinks menu in Spanish, English and French. Korean labels sit over the lines, such as cold drinks, still water 500 ml, Heineken bottled beer and hot drinks, and a pill at the top reads Pippa is reading 1 line.
The live lane over a drinks menu. Every label is mine and lands over the line it translates; the pill at the top says I am still reading one line.

The trick is to split the job into two halves that run at very different speeds. Finding where text sits in the frame is a position problem, and the phone solves it on the device, frame after frame. Saying what the text means is my job, and it costs a network round trip and a few seconds of a brain. The scanner never waits for me. It knows where every item sits from the first frame, but it draws nothing there: a label appears over a text only when my answer for it lands.

Phone only, on purpose

This lane lives in one file, LiveCameraView.swift, inside SparkMobile. The web client has no live camera and no call to the live route. It can list a live session afterwards, with a line like 4 seen live, and it shows the Live camera brain slot in its picker. It cannot open one. The scanner itself needs real hardware: the view checks DataScannerViewController.isSupported first, and on the Simulator it shows This device has no scanner instead of a camera.

VisionKit does the looking

Apple's VisionKit ships DataScannerViewController, a complete camera view that recognizes text and keeps tracking each recognized item as the phone moves. Spark wraps it in a UIViewControllerRepresentable and configures it in the first code block below. Three choices matter. recognizesMultipleItems: true, because a sign board is many items. isHighFrameRateTrackingEnabled: true, so an item's bounds keep up with a moving hand. And isHighlightingEnabled: false, because Spark draws its own labels and wants no system highlight boxes underneath them. That choice has a cost you can see. The scanner tracks every item's bounds from the first frame, but nothing marks them on screen, so until my answer lands the camera shows the plain picture.

The scanner reports through its delegate: an item was added, updated or removed. Each RecognizedItem carries a stable id, the transcript, and four corner points. The coordinator keeps a dictionary from item id to text and bounds. On every add or update it calls lane.need(text) and redraws. When an item leaves the frame, its label leaves with it.

Asking once per text

need is called far more often than I should be asked, because every small movement fires an update. So the lane keeps three collections keyed by a normalized text (whitespace collapsed, lowercased): translations already known, asked on their way, and waiting to be sent. A text found in any of them is ignored. A genuinely new one goes into waiting and schedules a flush 350 ms later.

That short sleep turns a burst into a batch. When the phone swings onto a menu, a dozen items appear within a few frames. Each schedules its own flush, but the first one to wake takes up to 12 waiting texts in one call, and the flushing flag sends the others home at once. A flush that finishes with more still waiting calls itself again, meaning to drain the rest. One request per burst, not one per line.

That second call does less than it looks. It runs inside the first one, before the defer has lowered flushing, so its own guard sees the flag still up and returns at once. In a burst of more than 12 new texts, the first 12 are asked and the rest stay in waiting. They go out only if a flush scheduled by a later new text wakes after this one has ended. If the scanner reports nothing new, they stay unlabelled, and need will not queue them again, because they are already waiting. Lowering the flag before the tail call, or looping inside one flush until waiting is empty, would drain them.

Failure is handled per key. If the call throws, every key in the batch leaves asked, so the next time the scanner reports that text it is asked again. If the engine answers but could not translate some keys, it lists them as pending, and only those are released. The status capsule at the top says what happened in plain words: Pippa is reading 4…, 12 translated, or the error itself.

Drawing over the text

The overlay is ordinary UIKit. Each item with a translation gets a UILabel in the scanner's overlayContainerView: white text on a 62% black patch, rounded corners, at most two lines, shrinking to 40% before it truncates. Its frame is the bounding box of the four corners, padded a little, and its font is 70% of that height, held between 11 and 30 points. The label is keyed by the item's id and repositioned on every update, so it follows the sign as Dad moves. Items I have not answered yet stay bare. No placeholder pretends to be a translation.

The translation table is keyed by text, not by item. Two identical signs in one frame, or the same sign seen again after it left the view, get their label at once from the first answer.

Principle: Split work by how fast each half can be. Positions are local and instant; meaning is remote and slow. The fast half never waits for the slow one.

Two more pieces finish the lane. Lesson 2 shows which brain answers and how that was decided. Lesson 3 shows what the engine does with the batch before any brain sees it. If you want the general iOS side (wrapping UIKit in SwiftUI, delegates, extensions), Swift Quest covers it. Its running example is also called Spark, but that Spark is an invented app, not this one.

Code

cwkSpark, clients/apple/SparkMobile/Sources/Views/LiveCameraView.swift: the scanner Spark configures (verbatim, lines 163-177)·swift
    func makeUIViewController(context: Context) -> DataScannerViewController {
        let scanner = DataScannerViewController(
            recognizedDataTypes: [.text()],
            qualityLevel: .balanced,
            recognizesMultipleItems: true,
            isHighFrameRateTrackingEnabled: true,
            isPinchToZoomEnabled: true,
            isGuidanceEnabled: false,
            isHighlightingEnabled: false)
        scanner.delegate = context.coordinator
        context.coordinator.scanner = scanner
        handle.scanner = scanner
        lane.onUpdate = { [weak coordinator = context.coordinator] in coordinator?.redraw() }
        try? scanner.startScanning()
        return scanner
cwkSpark, clients/apple/SparkMobile/Sources/Views/LiveCameraView.swift: the lane asks once per text, a batch at a time (verbatim, lines 98-143)·swift
    static func key(_ text: String) -> String {
        text.split(whereSeparator: \.isWhitespace).joined(separator: " ").lowercased()
    }

    func translation(for text: String) -> String? { translations[Self.key(text)] }

    /// The scanner saw this text: translate it unless it is known or asked.
    func need(_ text: String) {
        let key = Self.key(text)
        guard !key.isEmpty, translations[key] == nil, !asked.contains(key), waiting[key] == nil else { return }
        waiting[key] = text
        Task { await flushSoon() }
    }

    private func flushSoon() async {
        try? await Task.sleep(for: .milliseconds(350))
        await flush()
    }

    /// One call to the live lane for what is waiting, a batch at a time.
    func flush() async {
        guard !flushing, let client, let momentId, !waiting.isEmpty else { return }
        flushing = true
        defer { flushing = false }
        let batch = Array(waiting.prefix(Self.batchLimit))
        for (key, _) in batch {
            waiting[key] = nil
            asked.insert(key)
        }
        status = "Pippa is reading \(batch.count)…"
        do {
            let reply = try await client.live(momentId: momentId,
                                              items: batch.map { .init(key: $0.key, text: $0.value) })
            for item in reply.items {
                if let translation = item.result.translation { translations[item.key] = translation }
            }
            // Untranslated keys may be asked again when the text comes back.
            for key in reply.pending { asked.remove(key) }
            status = reply.error.map { "Pippa: \($0)" } ?? "\(translations.count) translated"
        } catch {
            for (key, _) in batch { asked.remove(key) }
            status = error.localizedDescription
        }
        onUpdate?()
        if !waiting.isEmpty { await flush() }
    }
cwkSpark, clients/apple/SparkMobile/Sources/Views/LiveCameraView.swift: the coordinator tracks items and draws labels over them (verbatim, lines 210-234)·swift
        private func track(_ recognized: [RecognizedItem]) {
            for item in recognized {
                guard case .text(let text) = item else { continue }
                items[item.id] = (text.transcript, text.bounds)
                lane.need(text.transcript)
            }
            redraw()
        }

        /// Each translated item gets a label over its outline; the rest stay
        /// bare until Pippa's answer lands.
        func redraw() {
            guard let overlay = scanner?.overlayContainerView else { return }
            for (id, item) in items {
                guard let translation = lane.translation(for: item.text) else {
                    labels.removeValue(forKey: id)?.removeFromSuperview()
                    continue
                }
                let label = labels[id] ?? makeLabel(in: overlay, id: id)
                label.text = translation
                let frame = Self.frame(item.bounds)
                label.frame = frame.insetBy(dx: -4, dy: -2)
                label.font = .systemFont(ofSize: min(max(frame.height * 0.7, 11), 30), weight: .semibold)
            }
        }

External links

Exercise

Build a two-speed text overlay. Use any on-device text detector you have (VisionKit on iOS, ML Kit on Android, or a desktop OCR library over webcam frames) to find text boxes every frame, and send each new text to a slow translator: a stub that sleeps two seconds is enough. Keep the three collections from this lesson (known, asked, waiting) keyed by a normalized text, flush after a short delay, and release only the failed keys on an error. Check that a burst larger than one batch still drains completely. Then pan slowly across a printed page and count the translator calls per minute with the delay at 0 ms and at 350 ms.
Hint
With no delay, almost every new line becomes its own call, because each one arrives a frame or two after the last. With a short delay and a single flushing flag, the burst collapses into one or two calls. If you see the same text asked twice, check that the key is normalized identically in the guard and in the lookup.

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.