Idea, Concept, and Technology

アイデアを形に

空をタップして雲に名前を付ける — CloudWatcher の AR ラベリング機能を作った話

CloudWatcher 2.2.0 で AR ラベリングモードを追加しました。iPhone のカメラを空に向けてタップすると、その雲の名前 (巻雲・積雲・積乱雲など) が 3D 空間にピンとして固定される 機能です。視点を動かしてもラベルは空に張り付き続けるので、複数の雲種が混在する空でも「あれは何雲、これは何雲」と立体的に確認できます。

実装はとても薄いコードで成立していて、ARKit と RealityKit と vision LLM API を素直に組み合わせただけ — ただし、「空に対する AR」という普通の AR とちょっと違う前提 がいくつかあって、そこを工夫した結果きれいに動くようになりました。今回はそこを書きます。

デモ

実機での動作は YouTube に上げました:


www.youtube.com

40 秒ほどでだいたいの挙動が伝わるはずです。

「空に対する AR」が普通の AR と違う点

ARKit の典型的な使い方は、平面検出 (planeDetection = [.horizontal, .vertical]) で床や壁を見つけて、そこに raycastResult でオブジェクトを置く、というものです。机に虎の置物を置く、壁にポスターを貼る、みたいなアプリ。

空にはこれが効きません。

  • 平面が検出できない (空は平面ではない、というかそもそも実体がない)
  • raycast できない (奥行きが取れない、無限遠)
  • 特徴点が拾いづらい (青一色のグラデーション部分は ARKit のトラッキング精度が落ちる)

なので「タップしたところに 3D ラベルを固定する」というシンプルな要件に対しても、ちょっと工夫が必要でした。

解決法 1: ラベルは固定距離 50m に置く

タップ位置から伸ばしたレイの方向ベクトルだけ取って、深さは固定値 50m で打ち切る。

guard let ray = arView.ray(through: tapPoint) else { return }
let placementDistance: Float = 50
let position = ray.origin + ray.direction * placementDistance

雲は実際には数百 m〜数 km 先にありますが、AR ラベルとして見せる用途では 50m あれば十分遠く感じます。ARKit の特徴点トラッキングが効いている前提なので、この相対座標は カメラを動かしてもワールド空間に固定されたまま になります。これで「空に張り付く」挙動が成立します。

平面検出は不要なので切っています。環境テクスチャも切ってオーバーヘッド削減:

let config = ARWorldTrackingConfiguration()
config.planeDetection = []
config.environmentTexturing = .none

解決法 2: 仮ラベル "…" を即座に置く

タップしてから雲種の判別が返ってくるまで、サーバラウンドトリップが入ります (CloudWatcher は OpenAI Vision API を Cloudflare Workers 経由で呼んでます)。これを真面目に待つと「タップしたのに何も起きない無の 2〜3 秒」が発生して UX が悲惨。

なので、タップした瞬間に "…" だけ書かれた仮アンカー をその位置に置いてしまう:

let pendingAnchor = makeAnchor(at: position, text: "…", subtitle: nil)
arView.scene.addAnchor(pendingAnchor)

// バックグラウンドで AI 解析、結果が返ってきたら差し替え
arView.snapshot(saveToHDR: false) { image in
    Task { await self.analyze(image: image, anchor: pendingAnchor, position: position) }
}

判別が終わったら仮アンカーを除去して、本物のラベル (例: 巻雲 / 87%) を同じ座標に置き直す。位置情報だけ先に確定させて、内容を後から流し込む形。「待たされてる感」が大きく減る のでおすすめです。

解決法 3: 低信頼度はラベルを残さない

OpenAI Vision の応答には独自の信頼度スコアが付いてきます。閾値 0.3 未満や unknown カテゴリの場合は、ラベルを残さずに上部バナーで「判別できませんでした」と出すだけ:

if result.cloudType == .unknown || result.confidence < 0.3 {
    removePending(pending)
    parent.lastError = L("ar_could_not_identify")
    return
}

空中に意味のない "?" や誤判別ラベルが残るのは AR 体験としてかなり邪魔なので、自信がない時は黙る が基本方針。これは初版で残したまま誤ラベルが空中に散らばってしまう挙動だったのを、ユーザーフィードバックで気づいて直した部分です。

解決法 4: ラベルは静的 billboard

ラベルが常にカメラの方を向く「動的 billboard」は AR でよくある実装ですが、毎フレームで全アンカーを回転計算するのは CPU 負荷が地味に高い。

CloudWatcher の用途では 配置した瞬間にカメラ方向を向かせて、そのまま固定 で十分:

if let cameraPos = arView?.cameraTransform.translation {
    let direction = normalize(cameraPos - position)
    let yaw = atan2(direction.x, direction.z)
    anchor.transform.rotation = simd_quatf(angle: yaw, axis: [0, 1, 0])
}

50m 先の遠いラベルなので、ユーザーがその後 30 度くらい横に動いても、見た目の不自然さはほぼゼロです。フレームごとの追従はやらない。

解決法 5: 視認性のための白プレート

最初は文字だけ浮かせていましたが、空が明るすぎる/暗すぎると文字が読みづらくなる。半透明 (アルファ 0.85) の白いプレートを文字の裏に敷いたら、夕焼け空でも雷雲の暗い空でもちゃんと読めるようになりました:

let material = UnlitMaterial(color: UIColor.white.withAlphaComponent(0.85))

UnlitMaterial を使ってるのは、AR 環境光に影響されたくないから (空が暗いとプレートまで黒くなってしまう)。

限界

  • 奥行きが本当の意味では正しくない: ラベルはカメラから 50m 固定で、雲そのものの距離じゃない。同じ視線方向に手前と奥の雲が並んでいた場合、奥の雲を分類したつもりでも手前の雲のラベルがついてしまう
  • ARKit のトラッキングが切れると即崩壊する: 急にカメラを下向けて地面の特徴点を失うと、空の方向のワールド座標が一気にずれる場合がある。これは ARKit の限界
  • API 呼び出しコストはかかる: 1 タップごとにサーバ経由で vision LLM が走る。タップ連打されるとキューが詰まるのでデバウンス的な制御は今後検討中

まとめ

ARKit + RealityKit の組み合わせは「空に対しての AR」みたいな変則ユースケースでも、平面検出を切って固定距離アンカーを使うパターンで意外とサクッと動きます。「タップした位置の方向ベクトルだけ取って、深さは捏造する」 は AR/空間 UI でちょいちょい出てくる手法なので、覚えておいて損はないかもしれません。

CloudWatcher は 2011 年から続いている、雲を観察するための小さな iPhone アプリです。AR ラベリングの他にも、過去バージョンに記録されたデータを自動復旧する処理を 2.2.0 で組み込んでいます。

ダウンロード: CloudWatcher on App Store