はじめに:なぜ「画面文字翻訳」を自作したのか

Mac で英語のドキュメント、エラーログ、海外 SaaS の UI、arXiv の 2 カラム論文などを読んでいるとき、多くの開発者が次のような不満を感じたことがあるはずです。

  1. **コピペできないテキストが多すぎる**:

Canvas 描画された UI、Figma のデザインモック、動画・生配信の字幕、画像化されたスライド、ターミナルの特殊なレンダリング領域など、マウスドラッグで選択できない文字列が日常に溢れています。

  1. **クラウド OCR のレイテンシ問題(2〜3秒の断絶)**:

一般的な画面翻訳ツールは「画面キャプチャ → クラウドへ画像アップロード → クラウド側で OCR → 翻訳 → 結果受信」という往復が発生します。どんなに速くても 1.5 秒〜3 秒かかり、開発時の集中状態(フロー状態)が完全に途切れてしまいます。

  1. **機密コードや NDA のセキュリティリスク**:

開発中の未公開コードや顧客データの画面スクリーンショットが外部サーバーに送信・蓄積されるのは、企業のセキュリティポリシーや NDA の観点から絶対に許容できません。

「ショートカットキー(⌃S)を押して矩形選択した瞬間、0.3 秒以内にマウスの横に翻訳結果がピン留めされる体験」を作りたい――。そう考えて、Apple Silicon の Neural Engine と Vision フレームワークをフル活用した完全ローカル OCR パイプラインを Swift で組み上げました。

本記事では、この超低遅延画面翻訳エンジンを構築する過程で得られた知見と、macOS 特有の座標反転トラップ、Apple Vision の空間ソートアルゴリズム、非アクティブ浮動 HUD の実装テクニックをすべて公開します。


1. 0.3秒パイプラインの全体アーキテクチャ

0.3 秒(300ms)という目標を達成するためには、一切の無駄な通信やメモリコピーを排除する必要があります。

[ グローバルショートカット (⌃S) ]
               │
               ▼
[ 選択矩形のキャプチャ (CGWindowListCreateImage) ] ── 約 12ms
               │ (RAM 内の CGImage 参照渡し)
               ▼
[ Apple Vision Neural Engine OCR (VNRecognizeTextRequest) ] ── 約 82ms
               │ (バウンディングボックスの正規化座標)
               ▼
[ 空間ソート & スマート段落結合 (TextProcessor) ] ── 約 1ms
               │ (プレーンテキスト)
               ▼
[ 翻訳エンジンへの非同期ディスパッチ / ストリーム受取 ] ── 約 175ms
               │
               ▼
[ フォーカス非奪取 NSPanel によるオーバーレイ表示 ] ── 約 2ms
────────────────────────────────────────────────────────
 合計遅延:約 272ms (0.3秒以内で完了)

画像ファイルとしてディスク(SSD)に書き出す処理は一切行わず、メモリ上の CGImage のまま Apple Vision の Neural Engine パイプラインに流し込みます。


2. 実装の急所①:AppKit 座標と CoreGraphics 座標の「反転トラップ」

macOS 開発者を最も悩ませるのが、AppKit と CoreGraphics の Y 軸反転です。

  • **AppKit(画面描画・イベント)**:画面**左下**が原点 `(0, 0)`、上に向かって Y が増加
  • **CoreGraphics(CGWindowList 画面キャプチャ)**:画面**左上**が原点 `(0, 0)`、下に向かって Y が増加

さらにマルチディスプレイ環境では、プライマリディスプレイ(メニューバーがある画面)の高さが基準になります。ここを雑に処理すると、マルチモニタ時に明後日の方向がキャプチャされてしまいます。

キャプチャ座標の正規化実装

import Cocoa
import CoreGraphics

public func captureScreenRect(appKitScreenRect: CGRect, excludingWindowNumber: CGWindowID) -> CGImage? {
    guard let mainScreen = NSScreen.screens.first else { return nil }

    // プライマリ画面の高さを基準に Y 軸を反転計算
    let primaryHeight = mainScreen.frame.height
    let cgRect = CGRect(
        x: appKitScreenRect.origin.x,
        y: primaryHeight - appKitScreenRect.origin.y - appKitScreenRect.height,
        width: appKitScreenRect.width,
        height: appKitScreenRect.height
    )

    // 最適解像度かつウィンドウ枠を除外してメモリ上へ直接キャプチャ
    return CGWindowListCreateImage(
        cgRect,
        .optionOnScreenBelowWindow,
        excludingWindowNumber,
        [.bestResolution, .boundsIgnoreFraming]
    )
}

この処理にかかる時間はわずか 10〜15ms。ディスク I/O を介さないため、ボトルネックになりません。


3. 実装の急所②:Apple Vision (`VNRecognizeTextRequest`) の極限チューニング

macOS に内蔵されている Apple Vision フレームワークは、M1/M2/M3/M4 チップの Apple Neural Engine (ANE) で直接実行されます。オープンソースの Tesseract OCR が CPU で 400〜600ms を要するのに対し、Vision は 80ms 前後で完了します。

しかし、設定を間違えると日本語の認識精度が落ちたり、コードの記号が誤変換されたりします。

本番運用の Vision リクエスト実装

import Vision

public func recognizeText(in cgImage: CGImage) -> String {
    var lines: [String] = []
    
    let request = VNRecognizeTextRequest { req, _ in
        guard let observations = req.results as? [VNRecognizedTextObservation] else { return }
        
        // 空間ソート:行ごとの Y 座標クラスタリング
        let sorted = observations.sorted { a, b in
            if abs(a.boundingBox.midY - b.boundingBox.midY) > 0.025 {
                return a.boundingBox.midY > b.boundingBox.midY
            }
            return a.boundingBox.minX < b.boundingBox.minX
        }
        
        for obs in sorted {
            if let top = obs.topCandidates(1).first {
                lines.append(top.string)
            }
        }
    }
    
    // 高精度モデルを指定(Neural Engine が効くため遅延差は極小)
    request.recognitionLevel = .accurate
    request.usesLanguageCorrection = true
    
    if #available(macOS 13.0, *) {
        request.automaticallyDetectsLanguage = true
    }
    
    // ja-JP を先頭に設定して日本語と英語の混在認識率を最大化
    request.recognitionLanguages = ["ja-JP", "en-US", "zh-Hans", "ko-KR"]

    let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
    try? handler.perform([request])

    // フォールバック:空結果時は単一言語モデルでリトライ
    if lines.isEmpty {
        for fallbackLangs in [["ja-JP", "en-US"], ["en-US"], ["zh-Hans", "en-US"]] {
            var fallbackLines: [String] = []
            let fallbackReq = VNRecognizeTextRequest { req, _ in
                guard let observations = req.results as? [VNRecognizedTextObservation] else { return }
                let sorted = observations.sorted { a, b in
                    if abs(a.boundingBox.midY - b.boundingBox.midY) > 0.025 {
                        return a.boundingBox.midY > b.boundingBox.midY
                    }
                    return a.boundingBox.minX < b.boundingBox.minX
                }
                for obs in sorted {
                    if let top = obs.topCandidates(1).first {
                        fallbackLines.append(top.string)
                    }
                }
            }
            fallbackReq.recognitionLevel = .accurate
            fallbackReq.usesLanguageCorrection = true
            fallbackReq.recognitionLanguages = fallbackLangs
            try? handler.perform([fallbackReq])
            if !fallbackLines.isEmpty {
                lines = fallbackLines
                break
            }
        }
    }

    return lines.joined(separator: "\n")
}

ポイント:

  1. **`midY` の閾値クラスタリング(0.025)**:

Vision が返す boundingBox の Y 座標は厳密には微細な傾きで一致しません。abs(midY1 - midY2) > 0.025 で同一行判定を行わないと、同じ行の単語がバラバラの行に分割されてしまいます。

  1. **言語優先順位**:

ja-JP を配列の先頭に置くことで、英語技術文書中の日本語コメントや、日本語ドキュメント中の英単語の誤認(全角半角の混同など)を劇的に減らすことができます。


4. 実装の急所③:スマート段落結合(OCR 後処理)

OCR の生データは「画面上の折り返し改行」がそのまま入っています。これをそのまま LLM や翻訳 API に投げると、文の途中で切れて翻訳精度が著しく低下します。

特に論文(arXiv)や PDF では、単語途中のハイフネーション(con-\nnect) や 2カラムレイアウトの混入 が日常茶飯事です。

public enum TextProcessor {
    public static func smartMergeParagraphLines(_ rawText: String) -> String {
        let normalized = rawText
            .replacingOccurrences(of: "\r\n", with: "\n")
            .replacingOccurrences(of: "\r", with: "\n")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        guard !normalized.isEmpty else { return "" }

        let lines = normalized.components(separatedBy: "\n")
        var result: [String] = []
        var buffer = ""

        for line in lines {
            let trimmed = line.trimmingCharacters(in: .whitespaces)
            if trimmed.isEmpty {
                if !buffer.isEmpty {
                    result.append(buffer)
                    buffer = ""
                }
                continue
            }

            if buffer.isEmpty {
                buffer = trimmed
            } else {
                // 英単語末尾のハイフネーション解除 (例: "con-" + "nection" -> "connection")
                if buffer.hasSuffix("-") && trimmed.first?.isLetter == true {
                    buffer.removeLast()
                    buffer += trimmed
                } else {
                    // 和文と欧文のスペース処理
                    let lastChar = buffer.last!
                    let firstChar = trimmed.first!
                    if isCJK(lastChar) && isCJK(firstChar) {
                        buffer += trimmed // 日本語同士ならスペース不要
                    } else {
                        buffer += " " + trimmed // 英語ならスペースを挟む
                    }
                }
            }
        }

        if !buffer.isEmpty {
            result.append(buffer)
        }

        return result.joined(separator: "\n\n")
    }

    private static func isCJK(_ char: Character) -> Bool {
        guard let scalar = char.unicodeScalars.first else { return false }
        return (0x4E00...0x9FFF).contains(scalar.value) || // 漢字
               (0x3040...0x309F).contains(scalar.value) || // ひらがな
               (0x30A0...0x30FF).contains(scalar.value)    // カタカナ
    }
}

このわずか数十行の後処理を挟むだけで、翻訳エンジンの出力品質が劇的に向上します。


5. 実装の急所④:フォーカスを奪わない爆速浮動 HUD (`NSPanel`)

翻訳結果が出た瞬間、アクティブだった VS Code や Xcode、ターミナルのフォーカスが外れてしまうと、タイピングが中断されて強烈なストレスになります。

これを防ぐには、通常の NSWindow ではなく、.nonactivatingPanel スタイルマスクを持った NSPanel を使用します。

final class FloatingHUDPanel: NSPanel {
    init(contentRect: NSRect) {
        super.init(
            contentRect: contentRect,
            styleMask: [.nonactivatingPanel, .borderless, .fullSizeContentView],
            backing: .buffered,
            defer: false
        )

        // 最前面フロート
        self.level = .floating
        self.isFloatingPanel = true
        self.hidesOnDeactivate = false
        
        // 背景と影のチューニング
        self.backgroundColor = .clear
        self.isOpaque = false
        self.hasShadow = true
        
        // 全スペース(仮想デスクトップ)で追従表示
        self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
    }

    // キーボードフォーカスを奪わない
    override var canBecomeKey: Bool { false }
    override var canBecomeMain: Bool { false }
}

これにより、ユーザーがコードを書きながら、マウスの横にポップアップした翻訳結果を視界に入れるだけで、一切の操作を中断せずに作業を継続できます。


6. 実測ベンチマーク(M2 MacBook Air / M3 Max)

実際の測定値を共有します。テスト画像は「英語の GitHub Issue 画面(約 150 単語)」です。

フェーズ処理内容所要時間 (M2 Air)所要時間 (M3 Max)備考
Phase 1`CGWindowListCreateImage` 画面キャプチャ12.4 ms8.1 msRAM 内ゼロコピー
Phase 2Apple Vision (`VNRecognizeTextRequest`)84.6 ms61.2 msApple Neural Engine 加速
Phase 3`TextProcessor` 空間ソート & 段落整形0.8 ms0.5 msSwift 純粋演算
Phase 4翻訳 API ストリーム受取開始 (First Chunk)172.0 ms158.0 ms最適化 HTTP/2 接続
Phase 5`NSPanel` レンダリング描画2.1 ms1.8 msSwiftUI + CoreAnimation
**合計****ショートカット押下から画面表示まで****約 271.9 ms****約 229.6 ms****0.3 秒未満を安定達成**

クラウドにスクショをアップロードする従来ツールでは 1,800ms〜2,500ms かかっていたものが、270ms 前後まで短縮されました。体感的には「選んだ瞬間にすでに出ている」感覚です。


7. プライバシーとセキュリティについて

ローカル OCR の最大の強みは「画面のスクリーンショットが 1 バイトたりとも外部に送信されない」という点です。

  • **画像データ**:Mac の RAM 内でのみ展開され、SSD への一時保存すら行わずに破棄されます。
  • **テキストデータ**:ユーザーが設定した API エンドポイント(自前の OpenAI / Anthropic / Gemini / DeepL API キー、または内蔵枠)にのみ暗号化通信で送信されます。
  • **企業利用**:機密コード、未公開の特許資料、顧客の個人情報、Slack の社内チャットなどが外部サーバーに画像として蓄積されるリスクはゼロです。

まとめ:オープンに配布しています

この「macOS 原生 VisionKit × 0.3 秒ローカル OCR 画面翻訳」の仕組みは、自分自身の毎日の開発体験を爆速化するために作った個人開発ツール「Zipp(閃貼)」のコア実装そのものです。

同じ悩みを抱える Mac 開発者やエンジニアの方に使っていただけるよう、完全無料ダウンロードで公開しています。

  • **登録不要・完全無料ダウンロード**
  • **自前 API キー対応(BYOK)**:お気に入りのモデルやプロバイダを自由に設定可能
  • **内蔵無料枠ですぐに利用可能**
  • **グローバルショートカット `⌃S` 対応**

公式サイト(または Homebrew)からダウンロードできますので、毎日の英語ドキュメント読みやエラー解決にぜひ役立ててみてください。

  • 公式サイト:[https://zipp-mac.surge.sh](https://zipp-mac.surge.sh)
  • 4大ツール徹底比較レビュー:[https://zipp-mac.surge.sh/articles/mac-screen-translation-comparison/](https://zipp-mac.surge.sh/articles/mac-screen-translation-comparison/)

もし実装やパフォーマンスチューニングについて気になる点があれば、コメント欄でお気軽にディスカッションしましょう!