0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルOCR + クラウドLLMのハイブリッドアーキテクチャ ― 画像を送らないiOSアプリ設計

0
Posted at

AI分析アプリを作るとき、一番悩むのは 「ユーザーの画像をどこまでクラウドに送るか」 です。画像をそのままマルチモーダルLLMに投げれば実装はシンプルですが、ユーザーの体感プライバシーは大きく下がります。特にチャットのスクリーンショットは、相手の名前・アイコン・会話内容が全部入った究極のセンシティブ情報です。

恋愛メッセージ分析アプリ Relora では、この問題を 「OCRはiOS上でローカル完結、LLMだけクラウド」 というハイブリッド設計で解決しました。本記事はその設計判断と、iOS側・サーバー側のコード構造を解説します。

この記事で分かること

  • 「画像を送らない」ことでユーザーに提供できるプライバシー保証
  • iOS側で完結するOCRパイプラインの構造(VNRecognizeTextRequest
  • テキストのみをAPI Gateway → Lambda → Bedrockに送る通信設計
  • マルチモーダルLLMを使わない判断の技術的・経済的理由
  • ローカル処理とクラウド処理の責務分離
  • プライバシーポリシーと実装を一致させる重要性

背景: マルチモーダル vs ハイブリッド

Claude Sonnet 4.6やGPT-4oのマルチモーダルLLMに画像を直接投げれば、OCRも分析も1回で終わります。コード量は最小。でも次のコストがあります。

  1. プライバシー: スクショそのものがサーバーを経由する。「相手の顔アイコン」「LINE名」「プロフ画像」が全てAWS上に一時的に残る。
  2. 料金: 画像入力は同じ分析内容でもテキストより多くのトークンを消費する。Anthropicは画像を長辺1568pxに自動リサイズしたうえで (width × height) / 750 トークンとして課金するため、iPhoneスクショ1枚は実測で約1,500トークンになる。
  3. 速度: 画像認識とテキスト推論を同時にする分、レスポンスが遅い。

Reloraは次の理由でハイブリッドを選びました。

  • ユーザーに「画像は一切サーバーに送られない」と明言できる
  • Apple Vision OCRはローカルで高速(100ms程度)かつ無料
  • LLM入力はテキスト化された後なので、1分析あたりのトークン数を圧縮できる(Bedrock経由でQwen3 Next 80Bなら1分析0.2円未満、Sonnet 4.6でも約2円程度)
  • オフライン時も少なくともOCRまでは動作する

パイプライン全体

┌─────────────────────────────────────────────────────────┐
│  iOS (Relora.app)                                       │
│                                                          │
│  ① UIImagePickerController / PhotosPicker              │
│     ↓ UIImage                                           │
│  ② ImageAnalysisService                                │
│     - VNRecognizeTextRequest                            │
│     - boundingBox で [me]/[them] 判定                   │
│     - 複数行バブルを結合                                │
│     ↓ 構造化テキスト([them] 今日どうする? [me] ...) │
│  ③ CloudAnalysisService                                │
│     - Cognito JWT取得(Keychain保存)                   │
│     - HTTPS POST (JSON body, 画像なし)                  │
└─────────────────────────────────────────────────────────┘
                      ↓ HTTPS (TLS 1.3)
┌─────────────────────────────────────────────────────────┐
│  AWS Cloud                                               │
│                                                          │
│  ④ API Gateway (Cognito Authorizer)                    │
│  ⑤ Lambda (analyze)                                    │
│     - レート制限(DynamoDB アトミックカウンタ)         │
│     - プロンプト構築                                    │
│     - Bedrock Guardrails                                │
│  ⑥ Bedrock Converse API                                │
│     - Qwen3 Next 80B (free) / Sonnet 4.6 (premium)     │
└─────────────────────────────────────────────────────────┘
                      ↑ JSON
                   ↓ iOS側でパース → 画面表示

画像は ③ の送信バウンダリを決して越えない のが核です。

iOS側: ローカルOCRパイプライン

ImageAnalysisService の責務

actor ImageAnalysisService {
    /// UIImageから構造化チャットテキストを抽出する
    func structureChat(from image: UIImage) async throws -> String {
        let observations = try await recognizeText(in: image)
        let grouped = groupByBubble(observations)
        let labeled = assignSenderLabels(grouped, imageWidth: image.size.width)
        return labeled.map { "[\($0.sender)] \($0.text)" }
            .joined(separator: "\n")
    }

    private func recognizeText(in image: UIImage) async throws
        -> [VNRecognizedTextObservation]
    {
        guard let cgImage = image.cgImage else { throw OCRError.invalidImage }
        let request = VNRecognizeTextRequest()
        request.recognitionLevel = .accurate
        request.usesLanguageCorrection = true
        request.recognitionLanguages = ["ja-JP", "en-US", "ko-KR", "zh-Hans"]

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

VNRecognizeTextRequest は完全にデバイス内処理です。ネットワークを一切使わないため、機内モードでも動作します。

送信者判定

boundingBoxの minX / maxX で左右判定 → 同じサイドかつY座標が近いものを1バブルとして結合、という流れです。コードと設計判断の詳細は別記事 Apple Vision OCRのboundingBoxで送信者を自動判定する に書きました。

重要: 画像オブジェクトはメモリからも素早く解放

func analyze(image: UIImage) async throws -> Analysis {
    let extractedText = try await imageService.structureChat(from: image)
    // この時点で image 変数のスコープを抜けさせる
    // UIImage は ARC で解放される
    return try await cloudService.submit(text: extractedText, ...)
}

Swiftの仕様上、関数スコープを抜けたローカル変数は即座にARCで解放されます。image を後段に持ち越さない構造にすることで、メモリ上の画像データすら分析中には残らない ようにしています。

サーバー側: テキストのみ受け取るAPI

API契約

POST /v1/analyze
Authorization: Bearer <Cognito JWT>
X-App-Language: ja
Content-Type: application/json

{
  "extractedText": "[them] 今日どうする?\n[me] いいね!",
  "situation": "マッチングアプリで知り合った",
  "relationship": "出会って1ヶ月",
  "purpose": "次のデートに繋げたい",
  "userNote": "最近返信が遅い",
  "tier": "free",
  "userProfile": {"gender": "female", "ageRange": "20s"}
}

リクエストボディにバイナリフィールドが存在しない ことが、設計上のアピールポイントです。API GatewayのログやLambdaのCloudWatchログに、画像データが残る可能性が構造的にゼロになります。

Lambda側(抜粋)

def lambda_handler(event, context):
    claims = event.get("requestContext", {}).get("authorizer", {}) \
                  .get("jwt", {}).get("claims", {})
    user_id = claims.get("sub")
    if not user_id:
        return _error(401, "Unauthorized")

    body = json.loads(event.get("body", "{}"))
    extracted_text = body.get("extractedText", "")
    if len(extracted_text) > 10000:
        return _error(400, "extractedText too long")

    # ...プロンプト組み立て、Bedrock呼び出し

画像を受け取るパラメータは存在しない ため、誤って追加される未来もありません。API仕様がそのままプライバシー保証になります。

マルチモーダルLLMを使わない経済的理由

仮にスクショ1枚をそのままClaude Sonnet 4.6に投げた場合のコスト試算(Bedrock on-demand: input $3 / output $15 per 1M tokens、1USD=150円換算):

項目 マルチモーダル ハイブリッド
画像入力 約 1,500 tokens(リサイズ後) 0
OCR結果テキスト 0(同時処理) 約 300 tokens
コンテキスト(プロンプト等) 約 1,500 tokens 約 1,500 tokens
出力 約 500 tokens 約 500 tokens
合計トークン 約 3,500 約 2,300
1回あたりコスト(Sonnet 4.6 概算) 約 2.5円 約 1.9円
1回あたりコスト(Qwen3 Next 80B 概算) 約 0.13円

絶対額の差は1回あたり数十銭〜数円ですが、月10万分析を超える規模になると有意な差 になります。さらに重要なのはトークン経済よりプライバシー設計の一貫性で、ここがコストのみで判断するべきでない理由です。なお無料枠(Qwen3 Next 80B)はSonnetよりおよそ1桁以上安く、無料プラン提供を成立させる前提になっています。

プライバシーポリシーとの一致

設計とプライバシーポリシーを一致させるには、「送信しない情報」を明文化する のが重要です。Reloraのプライバシーポリシーは以下の記述を含みます。

本アプリは、分析対象の画像データをお客様のデバイス外に送信しません。画像からのテキスト抽出はApple Vision frameworkを用いてお使いのiPhone上で完結し、当社のサーバーおよび第三者のサービスに画像が送られることはありません。

実装と文言を1対1で対応させることで、「プライバシー訴求は誇張では?」という疑念を構造的に封じます。

ローカル処理とクラウド処理の責務分離

設計判断を整理すると:

処理 配置 理由
OCR ローカル (Vision) プライバシー + 無料 + 高速
送信者判定 ローカル 座標情報を使うため、サーバーでは不可能
コンテキスト入力UI ローカル ユーザーのモチベーション文脈
JWT認証 サーバー (Cognito) セキュリティ
レート制限 サーバー (DynamoDB) クライアント信頼できない
プロンプト構築 サーバー プロンプトを露出させない
LLM推論 サーバー (Bedrock) 無料枠とモデル選択の柔軟性
JSONパース ローカル 表示と密結合
履歴永続化 ローカル (SwiftData) プライバシー

「ユーザーデータに近い処理ほどローカル、計算リソースが要る処理ほどクラウド」 という原則で、責務を分離しています。

オフライン動作の扱い

ネットワーク切断時の挙動:

  • OCRは動作する(Vision はローカル)
  • 分析結果の取得はできない(UIでエラーを表示)
  • 過去の分析履歴はSwiftDataから読める

これは プライバシー第一設計の副次的メリット で、「圏外でも会話の内容を確認したい」というニーズに応えられます。

まとめ

  • マルチモーダルLLMに画像を直接投げるのは楽だが、プライバシー・コスト・速度で不利
  • 「OCRローカル + LLMクラウド」のハイブリッドは、iOS個人開発でも再現可能な設計
  • API仕様から画像受け取りを排除することで、プライバシーポリシーと実装を構造的に一致させられる
  • 1分析あたりのトークン数を3割程度カットでき、月数万〜数十万分析の規模では有意なコスト差になる
  • Vision frameworkは日本語・英語・韓国語・中国語に対応、遅延100ms前後で実用的

クラウドLLMを使う個人iOSアプリは、「画像は送らない」を構造で保証するだけで、ユーザーとの信頼関係がぐっと強くなります。

関連記事:

  • [Q1: Apple Vision OCRのboundingBoxで送信者を自動判定する]
  • [Q2: LLMのJSON出力を壊さない ― 多段デコード・自動修復パーサーの設計]
  • [Z1: スクショ→AI分析アプリの全体設計 ― iOS MVVM + AWSサーバーレス]

Relora(App Store): https://apps.apple.com/app/relora/id6762029713

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?