見出し画像

【なぜ、もう一つ足すのか CoreML & ARKit】iPad Swift Playgroundで「見る」をつくる

週刊プレイグラウンズ第40号
視覚サポートテクノロジー試行シリーズ


はじめに

39号まで、カメラ映像からボッチャの的球を見つけて位置を読み上げる仕組みを作ってきました。YOLOという物体検出モデルで球を探し、見かけの大きさから距離を推定する——という組み立てです。

今号から、これにARKitという別の技術を組み合わせていきます。iPadやiPhoneが空間そのものを認識する仕組みです。

ただ、いきなりコードを組み合わせる前に、一度立ち止まって説明しておきたいことがあります。なぜ、わざわざARKitを足すのか。すでにYOLOで球は見つかっているのに、もう一つ別の技術を組み合わせる理由は何なのか。

今号は、その説明の回です。「なぜ組み合わせるのか」を考えます。ここを知らないと、41号以降の統合作業が、ただの技術の寄せ集めに見えてしまうからです。

コードは載せますが、実は今号のコードには、すでに「タップした場所までの距離を測る」機能まで入っています。ただし、その計測がどういう仕組みで動いているかの解説は、次号(41号)に回します。今号はまず動かして、白い球が置かれて距離が出る——という体験だけ先に味わってください。仕組みの話は次号で、同じコードを使って説明します。

右上にボールがあります。離れているので小さめです。
キーボードの上にボールがあります。近いので大きめです。

YOLOにできること、できないこと

まず、これまで使ってきたYOLOの得意と不得意を整理します。

YOLOは、カメラ映像の中から「これは球だ」と見つけ出すのが仕事です。画面のどのあたりに、どのくらいの大きさで写っているか。それを四角い枠(バウンディングボックス)で囲って返してくれます。

これは、とても強力です。無数の物が写り込む映像の中から、的球らしきものだけを拾い上げる。色や形の手がかりも組み合わせれば、白い的球を選び出せる。ここまでは39号までで作ってきた通りです。

では、距離はどうでしょうか。

YOLOが返すのは、あくまで画面に写っている大きさです。そこから距離を出すには、「的球の実物は直径いくつ」という仮定を置いて逆算するしかありません。大きく写っていれば近い、小さく写っていれば遠い。理屈は単純です。

でも、この逆算にはいくつも弱点があります。

球の実寸を仮定しなければならない。的球とカラーボールで大きさが違えば、その仮定は崩れます。YOLOの枠は、球の本当の大きさより少し小さめに出る癖がある(37号で扱った片側誤差です)。しかもその誤差の量は、写り方によって変わる。5メートルでのキャリブレーション(38号)は、この逆算を実測で補正する工夫でした。

つまりYOLOは、「そこに球がある」ことは分かるが、「そこまで何メートルか」を知るための仕組みではないのです。距離を、球の見かけという間接的な手がかりからひねり出している。だから条件が変わると狂う。


ARKitにできること

そこでARKitの登場です。

ARKitは、異なる方法で距離を扱えます。床や壁などの平面(水平・垂直)や、空間そのものを認識するのです。

ざっくりと説明します。ARKitは、カメラの映像から「特徴点」——模様や角や明暗の差がある点——を認識します。持っているデバイスを少し動かすと、その特徴点がカメラの映像の中で少し移動します。その違いから、それぞれの点までの関係を計算します。これを繰り返すことでその空間を三次元に認識します。床がどこにあるか、壁がどこにあるか、それらが自分から何メートル離れているかが分かります。

少し補足
ARKitは、カメラ映像に加えて、本体に内蔵された動きのセンサー(加速度センサーやジャイロスコープ)の情報を組み合わせます。デバイスが実際にどれだけ動いたかという情報と、映像の中で特徴点がどれだけ動いたかを突き合わせることで、「この動きは実際に何センチの移動に相当するか」というスケールを割り出しています。カメラだけでも、動きのセンサーと合わせれば、距離をメートル単位で推定できる——というわけです。
また、iPhone/iPadのProシリーズには、LiDARセンサーを搭載しており、より正確に、より早く距離を測れます。

ここが大事なところです。ARKitは、そこに何があるかを知らなくても、そこまでの距離を知ることができます。「これは的球だ」とは分かりません。でも「この方向、この距離に、床の面がある」ことは分かる。YOLOとは正反対です。

  • YOLO:「これは的球だ」と分かる。でも距離は苦手。

  • ARKit:距離は正確。でも「これが的球だ」とは分からない。

今回の用途では、お互いを補完しようという発想です。


だから、組み合わせる

YOLOに「どれが的球か」を見つけさせ、ARKitに「そこまで何メートルか」を測らせる。これが以降でやっていく統合の骨子です。二つの技術は役割が違うのです。片方は物を見分け、片方は空間を測る。

そして——ここが今号のコードにつながります。ARKitにとっては、距離を測る相手が「YOLOの見つけた的球」であっても「指でタップした場所」であっても、やることは同じです。画面上のある一点を指して、「そこまで何メートル?」と尋ねる。今号のコードでは、その「一点」を指のタップで与えます。将来の統合では、同じ一点をYOLOが与える。仕組みは変わりません。だから今号のタップは、統合の予行演習でもあるのです。


ARKitの、もう一つの力

ARKitには、距離を測れるだけでなく、もう一つ「使える機能」があります。一度見つけた場所を記録できます。

ARKitは空間を三次元で把握してます。そこでARKitに「ここに的球がある」と指定すると、その位置を空間に固定できます。この記録をアンカーと呼びます。

YOLOだけでは、毎回その場で的球を探す方法でした。
その時点で、的球を画面内に捉えて、認識させる必要があります。
でもARKitのアンカーで位置を記録しておけば、一旦、的球が画面から外れても、手前を誰かが横切っても大丈夫です。動かしたカメラを振り戻せば、そこに的球があります。

この永続性の仕組みは、統合が進んだ後の号で実装します。今は「ARKitはこういうこともできる」と、頭の隅に置いておいてください。組み合わせる理由は、距離だけではないのです。


今号のコード

コードを載せます。やることは、画面に映った床をタップすると、そこに的球(白・直径8.5cm)が現れて、そこまでの距離が表示される——というものです。

繰り返しになりますが、距離をどのように扱っているかは解説は次号(41号)で行います。今号はまず動かして、床をタップし、白い球が置かれる様子を見てください。タップした場所が遠ければ球は小さく、近ければ大きく描かれます。ARKitが空間の奥行きを正しく捉えているのがわかるでしょう。

iPad Swift Playground、iOS 18以降。アプリ設定の機能で、カメラの利用を許可してください。
なお、コードが先行していますので、生成AI協働コーディング用のファイル(.md)は、次号に添付します。

import SwiftUI
import ARKit
import RealityKit

// MARK: - モデル

@MainActor
final class TapModel: ObservableObject {

    @Published var statusText    = "床に向けてゆっくり動かし、床をタップしてください"
    @Published var planeDetected = false
    @Published var distanceText: String? = nil   // 直線距離(表示用)

    private var arView: ARView?
    private var timer: Timer?
    private var ballAnchor: AnchorEntity?

    // 的球の実寸(直径8.5cm → 半径4.25cm)
    private let ballRadius: Float = 0.0425

    func setup(arView: ARView) {
        self.arView = arView

        let config = ARWorldTrackingConfiguration()
        config.planeDetection = [.horizontal]
        arView.session.run(config, options: [.resetTracking, .removeExistingAnchors])

        // ★Playground対策:ARSessionDelegateを使わずTimerで平面を監視
        timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
            Task { @MainActor [weak self] in self?.checkPlane() }
        }
    }

    func teardown() {
        timer?.invalidate()
        timer = nil
        arView?.session.pause()
    }

    // MARK: 平面監視(タップできる状態になったかを伝えるだけ)

    private func checkPlane() {
        guard let arView, let frame = arView.session.currentFrame else { return }
        let planes = frame.anchors.compactMap { $0 as? ARPlaneAnchor }
        if planes.isEmpty {
            planeDetected = false
            statusText = "床を認識中..."
        } else if !planeDetected {
            planeDetected = true
            statusText = "床を認識しました。床をタップしてください"
        }
    }

    // MARK: タップ処理(画面上の一点 → raycast → 距離)
    //   ★この距離計算の中身は次号(41号)で解説する。

    func handleTap(at location: CGPoint, in arView: ARView) {
        // 画面のタップ位置から床へ光線を飛ばす
        let hits = arView.raycast(from: location,
                                  allowing: .existingPlaneGeometry,
                                  alignment: .horizontal)
        let hit = hits.first ?? arView.raycast(from: location,
                                               allowing: .estimatedPlane,
                                               alignment: .horizontal).first

        guard let result = hit, let frame = arView.session.currentFrame else {
            statusText = "タップした場所に床面が見つかりません"
            return
        }

        // 光線が床に当たった点(ワールド座標)
        let t = result.worldTransform.columns.3
        let worldPos = SIMD3<Float>(t.x, t.y, t.z)

        // カメラの位置
        let c = frame.camera.transform.columns.3
        let camPos = SIMD3<Float>(c.x, c.y, c.z)

        // 直線距離
        let distance = simd_distance(camPos, worldPos)
        distanceText = formatDistance(distance)
        statusText = "的球まで \(distanceText!)"

        // タップ地点に、的球(白・直径8.5cm)を実寸で配置
        placeBall(at: worldPos, in: arView)
    }

    // MARK: 的球の仮想配置

    private func placeBall(at floorPoint: SIMD3<Float>, in arView: ARView) {
        ballAnchor?.removeFromParent()

        let mesh = MeshResource.generateSphere(radius: ballRadius)
        var material = SimpleMaterial()
        material.color = .init(tint: .white)
        material.roughness = 0.4
        material.metallic  = 0.0
        let ball = ModelEntity(mesh: mesh, materials: [material])

        // 床に接地させる:中心を半径分だけ持ち上げる(球の底が床に乗る)
        let anchor = AnchorEntity(world: SIMD3<Float>(floorPoint.x,
                                                      floorPoint.y + ballRadius,
                                                      floorPoint.z))
        anchor.addChild(ball)
        arView.scene.addAnchor(anchor)
        ballAnchor = anchor
    }

    // 10cm単位に丸める
    private func formatDistance(_ meters: Float) -> String {
        let rounded = (meters * 10).rounded() / 10
        let m  = Int(rounded)
        let cm = Int((rounded - Float(m)) * 100 + 0.5)
        return cm == 0 ? "\(m)m" : "\(m)m\(cm)cm"
    }
}

// MARK: - ARビュー(タップ認識つき)

struct ARTapView: UIViewRepresentable {
    let model: TapModel

    func makeUIView(context: Context) -> ARView {
        let arView = ARView(frame: .zero)
        model.setup(arView: arView)

        // コーチングオーバーレイ(平面が見つかるまでApple標準のガイドを自動表示)
        let coaching = ARCoachingOverlayView()
        coaching.session = arView.session
        coaching.goal = .horizontalPlane
        coaching.activatesAutomatically = true
        coaching.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        coaching.frame = arView.bounds
        arView.addSubview(coaching)

        let tap = UITapGestureRecognizer(
            target: context.coordinator,
            action: #selector(Coordinator.handleTap(_:))
        )
        arView.addGestureRecognizer(tap)
        return arView
    }

    func updateUIView(_ uiView: ARView, context: Context) {}
    func makeCoordinator() -> Coordinator { Coordinator(model: model) }

    final class Coordinator: NSObject {
        let model: TapModel
        init(model: TapModel) { self.model = model }

        @objc func handleTap(_ gesture: UITapGestureRecognizer) {
            guard let arView = gesture.view as? ARView else { return }
            let location = gesture.location(in: arView)
            Task { @MainActor in self.model.handleTap(at: location, in: arView) }
        }
    }
}

// MARK: - 画面

struct ContentView: View {
    @StateObject private var model = TapModel()

    var body: some View {
        ZStack {
            ARTapView(model: model)
                .ignoresSafeArea()

            VStack {
                // ステータス
                HStack(spacing: 8) {
                    Circle()
                        .fill(model.planeDetected ? .green : .orange)
                        .frame(width: 10, height: 10)
                    Text(model.statusText)
                        .font(.system(size: 15, weight: .semibold, design: .rounded))
                        .foregroundStyle(.white)
                }
                .padding(.horizontal, 16).padding(.vertical, 10)
                .background(.black.opacity(0.6), in: Capsule())
                .padding(.top, 8)

                Spacer()

                // 距離表示
                if let d = model.distanceText {
                    HStack(spacing: 6) {
                        Image(systemName: "ruler")
                        Text("的球まで")
                            .foregroundStyle(.secondary)
                        Text(d)
                            .font(.system(size: 28, weight: .bold, design: .rounded))
                    }
                    .padding(16)
                    .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 16))
                    .padding(.bottom, 16)
                }
            }
            .padding(.horizontal, 16)
        }
        .onDisappear { model.teardown() }
    }
}

コードのポイントだけ、軽く触れておきます。

config.planeDetection = [.horizontal] で、床のような水平面を認識させています。今回はタップ先が床なので水平面だけにしていますが、.vertical を足せば壁も認識できます(壁の応用は41号で扱います)。

handleTap が、この号の心臓部です。タップした画面位置から床へ光線を飛ばし(raycast)、当たった場所までの距離を出して、そこに白球を置く。この距離計算の詳しい中身は、次号で解説します。


動かしてみてください

私の環境——iPad 第8世代、LiDARなし——では、問題なく動きました。
床が認識されたら、画面のいろいろな場所をタップしてみてください。タップするたびに、そこに白い球が現れて、距離が更新されます。

近くをタップしたときと遠くをタップしたとき、球の大きさが変わることに注目してください。同じ直径8.5cmの球なのに、遠くに置けば小さく、近くに置けば大きく描かれる。これは、ARKitが「その場所が自分からどれだけ離れているか」をちゃんと分かっているからです。

うまくタップできないこともあります。のっぺりした床、単色のカーペット、暗い場所では、光線が当たる床面がうまく定まりません。これは、ARKitが手がかりにする「特徴点」が足りないからです。

この特徴点の話は、iPhoneの「計測」アプリを使ったことがある方なら、心当たりがあるかもしれません。模様のある場所は測れるのに、真っ白でのっぺりした面は苦手。あれと同じ理由です。Appleが作った公式アプリでもこうなります。カメラに写った映像の中の特徴を手がかりにする——それがこの技術の土台だからです。

ぜひ、ご自身の部屋でいろいろと試してみてください。


このシリーズの立ち位置

このシリーズは、テクノロジーで当事者をサポートすることを目的にしています。サポートが必要な状況はさまざまです。どの技術が何の役に立つかは、その状況次第でしょう。
そこで記事では、「これが正解です」と示すことではなく、技術に何ができて何ができないかを、並べることだと思っています。

今号で伝えたかったのは、YOLOとARKitという二つの技術が、なぜ手を組むのかということでした。片方は物を見分け、片方は空間を測り、覚えている。二つ合わせることでサポートの可能性が広がると感じています。


次号

41号では、説明を省いた「距離をどう測っているのか」——タップした点から、どうやって何メートルという数字が出てくるのか——を解きほぐします。


このシリーズのコード・記事・関連ファイルは、すべて無料で公開しています。もし「役に立った」「続きが読みたい」と思っていただけたら、記事末尾のチップ機能で応援していただけると、次号を書く力になります。どうぞよろしくお願いします。

いいなと思ったら応援しよう!