見出し画像

【YOLOだけで、どこまで実用に近づけるか】iPad Swift Playgroundで「見る」をつくる

週刊プレイグラウンズ第37号:
視覚サポートテクノロジー試行シリーズ
生成AIコラボコーディングという方法


2026年7月時点の最新のiPadOS, iPad Swift Playgroundを利用 全コードはこの記事の最後に掲載しています。
今号から、全コードに加えて、シリーズに合わせた「二つの.md(マークダウンファイル)=レシピ」を用意し、より多くの方が、より効率的に試せるようにします。二つのレシピについては、次の増刊号をお読みください。

レシピは、アプリを動かす実機で開発できるiPadおよびSwift Playground用に記していますが、Xcodeでの開発にも利用できます。その場合は、「開発環境の絶対制約」を適宜更新してください。


はじめに

前号(第36号)では、YOLOが拾ってくる「sports ball」というラベルだけでは的球かどうか判断できない問題に、色という手がかりで答えを出しました。ラベルで「球っぽい何か」を大まかに絞り、最終判定は色に任せる二段構えです。

今号は、そこからさらに「YOLOのみという制約の中で、どこまで実用に近づけられるか」を、実機で見えてきた気づきをもとに詰めていきます。扱うのは3つです。見かけ直径のブレへの対処、距離の換算値によるチューニング、そして探索中に見つけたときの発話の切り替え。いずれも、机の上で考えていたのとは違う形で、実機が答えを教えてくれたものです。

なお、36号に掲載したコードには、開発が先行した関係で、これから解説する★37号の機能がすでに含まれていました。今号では、その★37号部分を「なぜそうしたのか」まで含めて改めて解説します。


1. 見かけ直径のブレ ── なぜ「平均」ではダメだったか

距離はピンホールカメラモデルで出しています。的球の実寸(白いジャックボールは直径86mm)は分かっているので、画面の中で的球が何ピクセルに見えるかが分かれば、距離が逆算できます。原理はシンプルです。

問題は、その「何ピクセルに見えるか」が、フレームごとにブレることでした。しかも、ただランダムに揺れるのではありません。実機で観察していて気づいたのは、検出矩形は、本来の大きさより小さい側にだけズレるという偏りです。大きく出ることはほとんどなく、時々きゅっと小さくなる。ブレが大小対称ではなく、片側、小さい方にだけ出でるのです。

理由はYOLOの物体認識の中にあります。YOLOには複数の検出する仕組みがあります。それぞれが異なる大きさの物体を担当しながら、同じ対象を取り合っています。この競合で、ときどき球の一部しか囲えていない小さめの矩形が勝ってしまう。だから誤差は「小さくなる方向」にしか出ないと考えられます。

ここで、もし単純平均でならしていたら、この小さめの外れ値に引きずられて、推定した直径は本来より小さくなります。直径が小さい=遠くにあると誤解する、ということです。

そこで採ったのが、直近の判定の中で、大きい方から上位数個だけを平均するという方法です。小さい側にしかブレないなら、大きい側の値のほうが真値に近いと判断できます。

修正前(単純平均のイメージ):

// 直近フレームの平均をとる → 小さい外れ値に引きずられる
return window.reduce(0, +) / CGFloat(window.count)

修正後(上位k平均):

private enum SizeRule {
    static let windowSize = 8   // 直近Nフレームぶん保持
    static let topK = 3         // うち大きい方からk個を平均
    static let centerJumpReset: CGFloat = 0.08   // 中心が跳んだら窓を破棄
    static let dropoutReset: TimeInterval = 1.0  // 検出が途切れたら窓を破棄
}

// 大きい順に並べて、上位k個だけの平均をとる
let top = diameterWindow.sorted(by: >).prefix(SizeRule.topK)
return top.reduce(0, +) / CGFloat(top.count)

判定には破棄条件をつけています。的球の中心が大きく跳んだとき(別の球に乗り移った、カメラを大きく振った)と、検出が一定時間途切れたときは、判定をいったん空にします。古い状況の直径を引きずらないためです。

ひとつ注意した点があります。この安定化した直径は、距離計算にだけ使い、画面に表示する矩形そのものには適用していません。表示は生の検出のまま、内部の距離計算だけを安定させる、という切り分けです。


2. 距離の換算値 ── 実測でしか合わせられないもの

ピンホールモデルは原理的には正しいのですが、実機に載せると、そのままでは距離がずれます。カメラの実効的な焦点距離、正方形に切り出したあとの実効画角、そして「矩形が小さめに出る」という先ほどの偏りが残った分。これらが積み重なって、読み上げる距離に一定のズレを生みます。

これは計算では追い込めません。実際に距離を測って、読み上げた値と突き合わせて、その比で補正するしかない。そこで、実測による換算係数をひとつ持たせました。

// ★37号:距離の換算値(実測による校正係数、デバイス依存)
// 常用条件(4倍ズーム・数m先)で実測し、実測距離 ÷ 読み上げ距離 を設定する
// 例:実測7.0mで読み上げが7m80cmなら 7.0 / 7.8 ≒ 0.90
var distanceCalibration: CGFloat = 1.0

今はコードに直接書く形にしていますが、読者が使うなら、この値を調整するスライダーを画面につけるのは比較的簡単だと思います。ひとつのデバイス、ひとつの使い方に合わせて一度決めれば、あとは固定で使えます。

この「計算で追い込めないものは実測でアンカーする」という考え方は、次号のキャリブレーションでさらに踏み込むことになります。


3. 探索中の発見 ── 「見つけた瞬間、その情報はもう古い」

ここが、今号でいちばん面白かったところです。

視覚障害のプレイヤーは、的球を探すためにデバイスを左右・上下にゆっくり動かします。スイープ(掃くように動かす)です。そして的球が画面に入った瞬間、アプリは「正面の右側にあります」と言おうとします。

ところが、この「右側」は、言い終わる頃にはもう正しくありません。カメラを右へ振りながら見つけたのなら、的球は画面の左端から入ってきて、中央を通り、右へ抜けていくからです。発見した瞬間の画面内の位置は、すでに過去の情報になっている。

最初はこれを、読み上げを速くすることで解決しようとしました。でも、根本的にずれている情報を速く言っても、ずれたまま速く言うだけです。

発想を変えました。座標が流れていった方向こそ、ボールが実在する側だ、という不変量を使うことにしたのです。カメラを右に振っていて、的球の座標が画面内を右へ流れていったなら、的球は「振っていった先=右」に実在する。だから、静止しているときとは言い方を変えて、過去形で伝えます。「的球、右、◯メートルにありました」と。

private func classifySweep() -> SweepResult {
    guard centerWindow.count >= SweepRule.minSamples,
          let first = centerWindow.first,
          let last  = centerWindow.last else { return .stationary }
    let dx = last.x - first.x
    let dy = last.y - first.y
    if abs(dx) >= SweepRule.minDisplacement,
       abs(dx) > abs(dy) * SweepRule.axisDominance {
        // 実機で確認した軸対応:座標が流れた側にボールが実在する
        return dx > 0 ? .right : .left
    }
    if abs(dy) >= SweepRule.minDisplacement,
       abs(dy) > abs(dx) * SweepRule.axisDominance {
        return dy > 0 ? .near : .far
    }
    return .stationary   // 斜め・微動 → 従来どおりの読み上げへ
}

静止して的球をとらえているときは、従来どおり現在形で「正面」「正面の右側」と伝えます。スイープ中に見つけたときだけ、過去形の「ありました」に切り替える。判定できないとき(斜めに動かした、ほとんど動いていない)は、安全側に倒して従来の読み上げに戻します。

左右の向きは、机の上では決められなかった

正直に書いておきます。この左右の対応づけ(座標が右に流れたら「右」と言うのか「左」と言うのか)は、何度も間違えました。カメラの向き、画像の座標系、ユーザーから見た向き。これらが重なると、頭の中だけでは符号が合っているか確信が持てません。

35号でも、36号でも、実機で動かしてみて初めて「逆だった」と気づいて直しています。36号に掲載したコードでは、この部分が

return dx > 0 ? .left : .right   // ← 36号掲載時点。実機で逆と判明

でした。今号のコードでは、実機で確認して

return dx > 0 ? .right : .left   // ← 実機修正後

に直しています。座標系の向きは、実機で一度確かめるまでは「仮説」として扱う。これは、このシリーズを通じて何度も学び直していることです。

「ゆっくり動かす」が、勝手に守られる仕組み

もうひとつ、この設計には副産物がありました。速くカメラを振ると、先ほどの窓の破棄条件(中心が大きく跳んだら窓を空にする)に引っかかって、スイープ判定ができなくなります。つまり、速く振ると方向を教えてもらえない。ゆっくり振ると教えてもらえる。

「ゆっくり動かしてください」と言葉で頼まなくても、ゆっくり動かした人だけが恩恵を受ける構造になっている。制約が、そのまま正しい使い方へ導いてくれる。狙ってそうしたわけではないのですが、結果的にいちばん気に入っている部分です。


4. この号のまとめ

YOLOのみ、という制約の中で、実機が教えてくれた3つの気づきに対処しました。矩形は小さい側にしかブレないから上位k平均で受ける。計算で追い込めない距離は実測でアンカーする。発見した瞬間の方向は古くなっているから、座標が流れた側を過去形で伝える。

どれも、机の上の理屈ではなく、コートに近い場所で実際に動かして初めて見えたものです。


予告 ── 予定を変更します

当初、第38号はARKit統合の予定でした。ARFrame.capturedImageに切り替え、レイキャストで距離を取ることで、今号で対処した「見かけ直径のブレ」という弱点そのものを解消する、という計画です。

これを変更します。

実機で試すうちに、YOLOのみの方式が、当初の予想よりずっと実用に近づけられそうだと分かってきたためです。ここでARKitへ移る前に、YOLO方式をもう一段ブラッシュアップして、いったん「YOLO単体での完成形」まで持っていくことにしました。

第38号では、次の2つを扱います。

  • ボッチャコートの構造を使ったキャリブレーション:ボッチャのコートは、プレイヤーから中央のクロスまでが規格で5mと決まっています。ゲーム前に、この5m地点の的球をデバイスで認識させ、そのときの見かけの大きさを「5mの基準」として記録します。メジャーで測らなくても、コートの規格そのものが定規になる、という考え方です。同時に、そのときのデバイスの傾きから、使う人の目線の高さも逆算します。立って使う人も、車椅子で使う人も、その人がその高さで校正するだけで合う仕組みです。

  • CoreMotionによる計測:前述のキャリブレーション時に、デバイスの傾き(俯角)を取得します。そうすることで、デバイスの傾きの深さで近くを探索しているのか遠くを探索しているのかがわかります。これで、自動で、遠くを見るときはズームを上げ、近くを見るときは下げることができそうです。コートは近いところほど広く左右に振る必要があるという幾何学的な事情に、ズームで応える試みです。

ARKit統合は、この「YOLO単体の完成形」を見届けたあとの号へ繰り下げます。YOLOでどこまでいけるかをやりきってから、ARKitで何が変わるかを見るほうが、比較としても分かりやすいはずです。

今号のコードには、次号で解説するCoreMotionとキャリブレーションの機能は、まだ含めていません。今号は、YOLOのみの完成形の一歩手前まで、として区切ります。


おわりに ── 応援について

このシリーズの記事も、コードも、添えている.mdファイルも、すべて無料で公開しています。これからも、そのつもりです。視覚サポートのための技術が、少しでも多くの人の手に届くように ── それがこの連載を続けている理由だからです。

そのうえで、もし記事が役に立った、続きを読んでみたいと感じてもらえたら、note のチップ(応援)で背中を押していただけると、とても励みになります。いただいた応援は、実機でのテストや、次の号の試行錯誤を続けていくための力になります。

もちろん、読んでいただくこと自体が、いちばんの応援です。ここまで目を通してくださって、ありがとうございました。次号でまたお会いしましょう。


掲載コードについて

今号のコードは、36号掲載分から★37号部分の左右対応を実機修正した版です。yoloV3.swiftは以前と同一のものを使ってください。

(37号の全コード:36号と同一です)


次のコードも以前と同一です。このコードについては、第34号をお読みください。
https://note.com/ipadtocode/n/n168fd81aa12c


yoloは、次のAppleのページから入手できます。
YOLOv3TinyInt8LUT.mlmodel
詳しくは第34号をお読みください。
https://note.com/ipadtocode/n/n168fd81aa12c


wklyPGs_context37.md(生成AI向けの文脈ファイル)
生成AIに、このアプリの前提を正しく引き継ぐための文脈ファイルです。開発の目的、Swift Playground特有の注意点、これまで実機で直してきた約束事などをまとめています。次に開発を手伝ってもらうAIの冒頭に読ませてお使いください(特定のAI専用ではありません)。
なおアプリを動かす実機で開発できるiPadおよびSwift Playground用に記していますが、Xcodeでの開発にも利用できます。その場合は、「開発環境の絶対制約」を適宜更新してください。


wklyPGs_reference37.md(人間向けの技術リファレンス)
自分で実際に動かして調整するときの手引きです。フィルタや距離の各パラメータがどこにあって、なぜその値なのか、実機で何を確かめればよいかをまとめています。コードを触りながら、隣に開いておく用の資料です。

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