チャットのスクリーンショットをOCRで読み取ると、テキストは取り出せるものの「どれが自分の発言で、どれが相手の発言か」は分からないままです。LINE、Instagram DM、WhatsApp、Tinderのようなメッセージアプリは左右の吹き出し位置で送信者を表現しているので、この情報を活用できればOCR結果をそのまま会話ログに変換できます。
Apple Visionフレームワークは認識したテキストの座標情報(boundingBox)を返してくれます。この座標を使って送信者(自分=[me] / 相手=[them])を自動判定するアルゴリズムを、恋愛メッセージ分析AIアプリ「Relora」で実装しました。本記事はそのコードと設計判断の記録です。
この記事で分かること
-
VNRecognizeTextRequestのboundingBoxを使った送信者自動判定アルゴリズム - Y座標の近接性で複数行の吹き出しをグループ化するロジック
- 既読マーク・タイムスタンプ・プレースホルダーなどメタデータのフィルタリング
- 中央寄せテキスト(スタンプ説明等)の扱い
- LINE / Instagram DM / WhatsApp などの異なるチャットUIへの汎用性
背景: なぜOCR座標が必要か
Reloraは、ユーザーがスクリーンショットを投入すると恋愛心理学ベースのAIが分析・返信候補を提案するアプリです。AIに送るテキストは次のような形式で構造化されている必要がありました。
[them] 今日、映画見に行かない?
[me] いいね、何見る?
[them] 最近話題のあれとか
[me] ok、時間あとで決めよう
[me] / [them] のラベルが欠けていると、LLMは「どちらが返信を求めている側か」を誤判定します。特に恋愛文脈では「相手の最新メッセージに対する返信を提案する」という前提が崩れると、AIの出力が「自分にアドバイスするつもりで、相手への助言を出してしまう」という致命的な破綻を起こします。
素朴な解決策と限界
最初に考えたのは「ユーザーに手動でタグ付けしてもらう」でしたが、複数枚のスクショを投入するユースケースでこれは現実的ではありません。LLMに「以下のOCR結果から送信者を推測してください」と投げる方法も試しましたが、推測の精度が不安定で、1ターン消費する割に失敗率が高い。
そこで注目したのがApple Vision OCRが返すboundingBoxです。
VNRecognizedTextObservationのboundingBox
VNRecognizeTextRequestの結果として返るVNRecognizedTextObservationには、認識したテキストの矩形座標が含まれています。
struct BoundingBoxInfo {
let text: String
let minX: CGFloat // 矩形の左端(0.0-1.0、画像の幅に対する比率)
let maxX: CGFloat // 矩形の右端
let midX: CGFloat // 矩形の中心X
let midY: CGFloat // 矩形の中心Y
let height: CGFloat
}
座標はすべて 0.0〜1.0 の正規化された値で、画像のサイズに依存しません。このminX/maxXこそが、送信者判定の鍵になります。
Vision座標系の注意点: boundingBoxは左下が原点です。画面の上下と感覚が逆なので、後段の処理では1.0 - midYで反転させています。
アルゴリズム設計
吹き出しの位置による送信者判定
チャットUIでは、自分の発言は右寄せ、相手の発言は左寄せで描画されるのが一般的です。これを判定基準にすると次のようなルールに落ちます。
var side: String {
// 吹き出しの右端が画面右端に近い → 右寄せ(me)
if maxX > 0.80 { return "R" }
// 吹き出しの左端が画面左端に近い → 左寄せ(them)
if minX < 0.25 { return "L" }
// 中央付近 → 判定不能(C)
return "C"
}
ポイントは midX(中心)ではなく maxX(右端)/ minX(左端)で判定していることです。長い吹き出しは中心が画面中央寄りに来るので、midXで判定するとLINEの長文メッセージが常に中央寄りに分類されてしまいます。
端の距離で判定することで、吹き出しの長さに依存せず安定したラベル付けができます。
閾値の根拠
maxX > 0.80 / minX < 0.25 の数値は実機のスクショで試行錯誤して決めました。LINE・iMessage・Instagram DM・Tinderのスクショでこの閾値を使うと、100枚のテストデータで送信者判定の精度は約95%まで到達しています。
残りの5%は次のようなエッジケースです。
- とても短い吹き出し(「ok」など)が中央寄りに見える
- システムメッセージ(「◯◯さんが入室しました」等)
- 画像メッセージの代替テキスト
これらは後述するメタデータフィルタと中央ケースのフォールバックで処理します。
実装: structureChat(from:)
以下がReloraのImageAnalysisServiceの中核実装です。
Step 1: Observationをソートして座標を抽出
let sorted = observations
.compactMap { obs -> (String, CGFloat, CGFloat, CGFloat, CGFloat, CGFloat)? in
guard let text = obs.topCandidates(1).first?.string else { return nil }
let box = obs.boundingBox
// (text, midX, y(上から), height, minX, maxX)
return (text, box.midX, 1.0 - box.midY, box.height, box.minX, box.maxX)
}
.sorted { $0.2 < $1.2 }
Y座標は 1.0 - box.midY で反転させて「上から下へ」の順に並び替えます。これで読み順通りに処理できるようになります。
Step 2: 複数行の吹き出しをグループ化
LINEの長文メッセージは複数行に折り返されるため、1つの吹き出しが複数のObservationに分解されます。これらを1つにまとめないと、「長文の途中で送信者が変わった」と誤解されてしまいます。
struct TextBlock {
var texts: [String]
var minX: CGFloat // 吹き出しの左端(最小値)
var maxX: CGFloat // 吹き出しの右端(最大値)
var topY: CGFloat
var bottomY: CGFloat
var side: String {
if maxX > 0.80 { return "R" }
if minX < 0.25 { return "L" }
return "C"
}
}
グループ化の判定は「Y座標が近い」+「X座標が同じ側」の2条件です。
// 前のブロックとの行間距離
let lineGapThreshold: CGFloat = height * 2.5
// 現在の行のside判定
let currentSide: String = maxX > 0.80 ? "R" : (minX < 0.25 ? "L" : "C")
// 同じ吹き出し判定: Y座標が近い + 同じ側(or Cは前の側に合流)
let canMerge: Bool
if let lastIdx = blocks.indices.last {
let last = blocks[lastIdx]
let yClose = abs(y - last.bottomY) < lineGapThreshold
let sideCompatible = (currentSide == last.side) || (currentSide == "C") || (last.side == "C")
canMerge = yClose && sideCompatible
} else {
canMerge = false
}
lineGapThreshold は文字高さの2.5倍としています。これは「行間が通常の行の2.5倍以上空いたら別メッセージ」というヒューリスティックで、同じ吹き出し内の自然な行間(約1.2-1.5倍)と別メッセージ間の余白(約3倍以上)を区別するために使います。
sideCompatible の判定で「CはLにもRにも合流可」としているのがポイントです。中央判定された短い行(例: 吹き出し末尾の絵文字だけの行)が、前の行の側に追従するようになります。
Step 3: メタデータのフィルタリング
チャットスクショには会話本文以外のノイズが大量に含まれます。
- 時刻(
18:42、午後 6:42) - 既読マーク(
既読、Read、✓✓) - オンライン状態(
オンライン中、Online) - プレースホルダー(
メッセージを入力、Type a message)
これらをフィルタしないとAIは「相手のメッセージ: オンライン中」のような解釈をしてしまいます。
private static let timePattern = try! NSRegularExpression(pattern: #"^\d{1,2}:\d{2}$"#)
private static let readReceiptPattern = try! NSRegularExpression(pattern: #"[<>Vv✓✔]{1,3}\s*$"#)
private static let metadataExact: Set<String> = [
"オンライン", "オンライン中", "Online", "online",
"既読", "配信済み", "Delivered", "Read", "送信済み", "Sent"
]
private static let metadataPrefixes: [String] = [
"メッセージを入力", "Type a message", "Aa",
"iMessage", "SMS/MMS",
]
完全一致のセット、前方一致のリスト、正規表現の3層でフィルタしています。正規表現は正確性と引き換えに速度が遅いため、単純なパターンは完全一致・前方一致で先に弾くように順序を組んでいます。
readReceiptPattern の [<>Vv✓✔]{1,3}\s*$ は、メッセージ末尾にくっついた既読マーク(読んだよ✓✓のようなケース)を除去するためのパターンです。マッチ行全体を捨てるのではなく、該当部分だけを削る点に注意してください。
Step 4: ブロックを送信者ラベル付きメッセージに変換
var messages: [(sender: String, text: String)] = []
var currentSender = ""
for block in blocks {
let fullText = block.texts.joined(separator: "")
let sender: String
switch block.side {
case "L":
sender = "them"
case "R":
sender = "me"
default:
// 中央寄せは判定不能。短すぎるものはスキップ、長文は前の発言者に追従
if fullText.count <= 10 { continue }
sender = currentSender.isEmpty ? "them" : currentSender
}
messages.append((sender, fullText))
currentSender = sender
}
中央ケース("C")のフォールバックがこの実装の現実解です。
- 短文(10文字以下): システムメッセージや絵文字単体の可能性が高いのでスキップ
- 長文: 折り返された吹き出しの可能性が高いので、直前の送信者に追従
「長文を直前の送信者に追従させる」判断は少し乱暴に見えますが、チャットの構造上「中央寄せの長文が単独で出現する」ケースは稀です。むしろ、吹き出しが画面幅いっぱいに広がって中央判定されてしまうケースを救うほうが精度向上に効きました。
Step 5: ラベル付きテキストとして出力
var lines: [String] = []
for msg in messages {
let label = msg.sender == "me" ? "[me]" : "[them]"
lines.append("\(label) \(msg.text)")
}
return lines.joined(separator: "\n")
出力例:
[them] 今日なにしてるの?
[me] 仕事終わって今帰り道
[them] おつかれさま〜
[them] 明日も忙しい?
[me] 午後は空いてるよ、どうしたの?
このフォーマットはそのままLLMに渡せます。AIは [them] の最新発言を「返信対象」として扱うようにプロンプト設計するだけで、自然な返信候補が得られます。
実機での精度検証
LINE、iMessage、Instagram DM、WhatsApp、Tinder、Bumbleの6種のスクショ各20枚、計120枚で検証した結果は次の通りです。
| アプリ | 送信者判定精度 | メタデータ誤取り込み |
|---|---|---|
| LINE | 97% | 0件 |
| iMessage | 95% | 0件 |
| Instagram DM | 93% | 1件(「アクティビティ」表示) |
| 96% | 0件 | |
| Tinder | 98% | 0件 |
| Bumble | 94% | 2件(マッチ情報バナー) |
エッジケースはアプリ固有のUI要素(バナー、ヘッダー)がほとんどで、アプリごとにメタデータリストを追加することで対応できます。汎用のロジックとしては、閾値調整なしでどのチャットUIにも使えるレベルに到達しました。
ハマったポイントと教訓
1. 横画面スクショの座標系が違う
boundingBoxはアプリUI上の「見た目の」座標ではなく、画像のピクセル座標に対する比率です。横画面でスクショを撮ると、minX < 0.25 のルールがそのままでは機能しません。
Reloraでは当初、入力画像を縦横比でチェックして横画面なら警告を出す設計にしました。将来的には画像のorientationを検出して座標を回転させる実装に変えたいところですが、99%のユースケースは縦画面スクショなので優先度を下げています。
2. ダークモードの既読マーク
ダークモードのLINEスクショで、既読マークの色が本文と区別しにくくなり、OCR結果に紛れ込むことがありました。色ベースのフィルタは難しいので、readReceiptPattern のような形状ベースのパターンマッチで対処しています。
3. 絵文字とVision OCR
絵文字はtopCandidates(1)で正しく認識される場合と、「?」として認識される場合があります。これはVision OCRの挙動なので私たちの制御外ですが、? 単独のブロックはメタデータとしてスキップする処理を追加することで実用上は問題を回避できました。
4. recognitionLevel = .accurateと速度のトレードオフ
.fastでも動きますが、日本語の漢字認識精度が落ちるので.accurateを採用しています。iPhone 15 Proで1枚あたり約0.5秒。Reloraはスクショを最大5枚まとめて投入できる仕様ですが、並列化はしていません。5枚で約2.5秒なので、ユーザー体験的には許容範囲です。
並列化しないのは、複数のVNImageRequestHandlerを同時に走らせると端末によってメモリ圧迫でクラッシュするケースがあったためです。actor分離したImageAnalysisServiceで直列実行する設計に落ち着きました。
汎用性: 恋愛アプリ以外への応用
このアルゴリズムはチャットUIを持つあらゆるアプリのスクショに応用できます。
- カスタマーサポートチャットのログ整形
- 教育チャットアプリの学習ログ構造化
- ゲームチャットのリプレイ生成
- ビジネスチャット(Slack, Teams)のスクショ分析
特にカスタマーサポートで「お客様の発言」と「オペレーターの発言」を分離できる価値は大きく、クレーム分析や応対品質評価の前処理として再利用可能です。
まとめ
Apple Vision OCRのboundingBox座標を使うと、チャットスクショの送信者判定がLLMに推論させるよりずっと高速かつ安定に実現できます。主要なポイントは以下です。
-
maxX/minX(端の距離)で左右判定する。midXはダメ - Y座標近接+side一致で複数行の吹き出しをグループ化する
- 閾値は
maxX > 0.80/minX < 0.25が実用解 - メタデータは完全一致・前方一致・正規表現の3層でフィルタする
- 中央判定ケースは前の発言者に追従させる(短文はスキップ)
Reloraではこの前処理のおかげで、LLMへの入力が一貫して [me] / [them] ラベル付きテキストになり、恋愛心理分析の精度が安定しました。
OCRスクショから構造化データを取り出す処理は、AIアプリの前段として汎用的に使えるパターンなので、似たようなユースケースがある方の参考になれば幸いです。
関連リンク
- Relora (App Store): https://apps.apple.com/app/relora/id6762029713
- Apple Developer Documentation: VNRecognizeTextRequest
- アプリ全体の設計はZennで公開中
