
Foundation Modelsのマルチモーダル機能でリングフィットアドベンチャーのリザルト画面から運動記録を取り出してみた
私は「NSEasyConnect」というアプリを開発しており、長年遊んでいるリングフィットアドベンチャーのスクリーンショットをVision.frameworkのOCRで解析して運動記録を数値化している。しかし、1 と 7 を取り間違えるような誤認識がよく発生しており、数値補正のロジックを別途実装して対処してきた。
そうした中で、Foundation Modelsのマルチモーダル機能を使えばこの処理をよりシンプルに置き換えられないか試してみることにした。マルチモーダル機能の基本的な使い方については以下の記事で紹介した。
本記事では、@Generable を使って画像から特定のフィールドを構造化データとして抽出する実装手順を紹介する。期待通りの結果が得られるまでに何度か設計を見直す必要があったため、その試行錯誤の過程も合わせて紹介する。同じような検証をしてみたい方の参考になれば幸いだ。
検証環境
- MacBook Pro (16インチ, 2023), Apple M2 Pro
- macOS Tahoe 26.4.1
- Xcode 27.0 Beta
- iPhone 16e 実機(iOS 27.0 Beta)
解析対象の画像
Nintendo Switchの「スマートフォンに送信」でスクリーンショットをカメラロールに保存できる。今回は以下の2枚を使って検証した。
1枚目は合計活動時間と合計消費カロリーの2フィールドが表示されているリザルト画面 (rfa1)。

2枚目は合計活動時間・合計消費カロリーに加えて、合計走行距離も表示されているリザルト画面だ (rfa2)。

リングフィットアドベンチャーではワークアウトの種類によって表示されるフィールドが異なる。走行距離はランニング系の種目をこなした日のみ表示される。
実装手順
手順1:プロジェクトの準備
Xcodeで新しいiOSプロジェクトを作成する。基本的なセットアップはマルチモーダル基礎編と同様だ。
解析対象のスクリーンショット2枚を .xcassets に追加しておく。ここでは rfa1・rfa2 という名前で追加した。
まずはボタンをタップしたら処理を実行してテキストで表示する簡単な画面を用意する。
import SwiftUI
import FoundationModels
struct ContentView: View {
@State private var text: String = ""
var body: some View {
ScrollView {
VStack(spacing: 16) {
Text(text)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
Button("Run", action: action1)
}
}
}
func action1() {
// ここに処理を追加する
}
}
手順2:@Generableで構造体を定義する
取り出したいフィールドを @Generable 構造体として定義する。RingFitResult はファイルのトップレベル(ContentView の外)に定義する。
最初に試みた設計から最終的な設計に至るまでの試行錯誤を記録しておく。
最初の設計(動かなかった版)
最初は以下のような設計を試みた。活動時間は「13分11秒」のような表示を秒数に変換させ、走行距離は Double? のオプショナルで表現する方針だ。
@Generable
struct RingFitResult {
@Guide(description: "Total activity time in seconds. Convert from minutes and seconds shown on screen (e.g. 8分56秒 = 536, 25分10秒 = 1510)")
var totalActivitySeconds: Int
@Guide(description: "Total calories burned as a decimal number in kcal (e.g. 29.48)")
var caloriesBurned: Double
@Guide(description: "Total running distance in km as a decimal number. Set to nil if the running distance is not displayed on the screen")
var runningDistanceKm: Double?
}
画像1(13分11秒・29.48kcal・走行距離なし)を3回解析したところ、すべて同じ結果が返ってきた。
活動時間(秒): 781
消費カロリー: 29.48
走行距離: Optional(-1.0)
消費カロリーは正しく取得できたが、活動時間と走行距離に2つの問題が見つかった。
問題1:活動時間の誤読(781秒、正解は791秒)
- 781 = 13 × 60 + 1(13分1秒として計算されている)
- 791 = 13 × 60 + 11(正解)
モデルが「11秒」を「1秒」と誤読したうえで秒への変換演算まで行っていた。読み取りと演算を同時にモデルへ委ねたことで、どちらでミスしているか判別しにくいという問題もある。
問題2:走行距離の nil 生成失敗(Optional(-1.0) が返ってきた)
Double? は型レベルでは nil を表現できるが、モデルは「数値の文脈では数値を返したい」という傾向が強いようで、-1.0 や 0.0 で代替しようとしていた。
また、問題1・2を解消する過程で description を何度も書き直す必要があり、英語で記述していると意図通りの意味になっているか確認しづらく、調整コストが高いと感じた。NEVER return -1 のような禁止文を思いついても、モデルへの伝わり方を体感しにくい。そこで最終設計では日本語の description を採用し、精度に影響が出ないかも合わせて検証した。
最終的な設計(動いた版)
問題1・2と英語 description の調整コストという3点を踏まえて、設計を以下のように変更した。
@Generable
struct RingFitResult {
@Guide(description: "活動時間の「分」の部分のみを整数で(例:'13分11秒'なら13)")
var activityMinutes: Int
@Guide(description: "活動時間の「秒」の部分のみを整数で、0〜59の範囲(例:'13分11秒'なら11)")
var activitySeconds: Int
@Guide(description: "合計消費カロリーをkcal単位の小数で(例:29.48)")
var caloriesBurned: Double
@Guide(description: "走行距離が数値で表示されていればtrue、'-'または表示なしならfalse")
var runningDistanceAvailable: Bool
@Guide(description: "走行距離をkm単位の小数で。runningDistanceAvailableがtrueのときのみ有効")
var runningDistanceKm: Double
}
設計変更のポイントを3点まとめる。
読み取りと演算を分離する
totalActivitySeconds を廃止し、activityMinutes と activitySeconds の2プロパティに分けた。モデルには数値の読み取りだけを任せ、秒への換算(分 × 60 + 秒)はアプリ側で行う。0〜59の範囲 の範囲制約を description に明示することで、11を1と誤読するリスクも下げられる。
Optional<Double> より Bool + Double の分離が安定する
runningDistanceKm: Double? を廃止し、runningDistanceAvailable: Bool と runningDistanceKm: Double のペアに分けた。nil を生成させるより Bool の二択の方がモデルが安定して判定できる。アプリ側で runningDistanceAvailable が false のときに nil として扱う。
なお、活動時間の問題を修正するために、プロンプトで調整したバージョンも何度か試したが、走行距離には Optional(0.0) や Optional(-1.0) が返ってきた。NEVER return -1 のような禁止文を description に追加しても安定しなかったため、Bool による分離が有効だった。
@Guide の description は日本語でも問題ない
マルチモーダル基礎編では公式ドキュメントのサンプルに倣って英語で記述することを推奨した。今回、英語版と同じ内容の日本語 description でも両画像3回ずつ同等の精度が得られることを確認した。コードの可読性を優先する場合は日本語で記述しても問題ない。ただし本検証はシンプルな数値読み取り中心のタスクであるため、より複雑な条件分岐や抽象的な判定が必要なケースでは差が出る可能性は残る。
手順3:画像1を解析する
action1() に処理を追加して画像1を解析する。
func action1() {
guard SystemLanguageModel.default.isAvailable else {
text = "Apple Intelligenceが利用できません"
return
}
let session = LanguageModelSession()
Task {
let uiImage = UIImage(named: "rfa1")
// UIImage → CGImage に変換(UIImageはAttachmentに直接渡せない)
guard let cgImage = uiImage?.cgImage else {
text = "画像の読み込みに失敗しました"
return
}
do {
let response = try await session.respond(
generating: RingFitResult.self
) {
"リングフィットアドベンチャーのリザルト画面です。各フィールドの値を取り出してください。"
Attachment(cgImage)
}
let result = response.content
let totalSeconds = result.activityMinutes * 60 + result.activitySeconds
let distance: Double? = result.runningDistanceAvailable ? result.runningDistanceKm : nil
print("活動時間(秒): \(totalSeconds)")
print("消費カロリー: \(result.caloriesBurned)")
print("走行距離: \(distance.map { "\($0) km" } ?? "なし")")
text = """
活動時間(秒): \(totalSeconds)
消費カロリー: \(result.caloriesBurned) kcal
走行距離: \(distance.map { "\($0) km" } ?? "なし")
"""
} catch {
text = "エラー: \(error.localizedDescription)"
print("エラー: \(error)\n\(String(reflecting: error))")
}
}
}
画像1の解析結果は以下の通りだ。3回とも同じ値が返ってきた。
活動時間(秒): 791
消費カロリー: 29.48 kcal
走行距離: なし
activityMinutes: 13・activitySeconds: 11 と読み取られ、アプリ側で 13 × 60 + 11 = 791秒 に変換できた。runningDistanceAvailable: false となり、走行距離が正しく「なし」として扱われていることも確認できた。
手順4:画像2を解析する
ContentView に action2() を追加し、走行距離フィールドが表示されている画像2で動作を確認する。action1() と構造がほぼ同じになるが、重複コードは手順5でまとめてリファクタリングする。
func action2() {
guard SystemLanguageModel.default.isAvailable else {
text = "Apple Intelligenceが利用できません"
return
}
let session = LanguageModelSession()
Task {
let uiImage = UIImage(named: "rfa2")
guard let cgImage = uiImage?.cgImage else {
text = "画像の読み込みに失敗しました"
return
}
do {
let response = try await session.respond(
generating: RingFitResult.self
) {
"リングフィットアドベンチャーのリザルト画面です。各フィールドの値を取り出してください。"
Attachment(cgImage)
}
let result = response.content
let totalSeconds = result.activityMinutes * 60 + result.activitySeconds
let distance: Double? = result.runningDistanceAvailable ? result.runningDistanceKm : nil
print("活動時間(秒): \(totalSeconds)")
print("消費カロリー: \(result.caloriesBurned)")
print("走行距離: \(distance.map { "\($0) km" } ?? "なし")")
text = """
活動時間(秒): \(totalSeconds)
消費カロリー: \(result.caloriesBurned) kcal
走行距離: \(distance.map { "\($0) km" } ?? "なし")
"""
} catch {
text = "エラー: \(error.localizedDescription)"
print("エラー: \(error)\n\(String(reflecting: error))")
}
}
}
画像2の解析結果は以下の通りだ。3回とも同じ値が返ってきた。
活動時間(秒): 1586
消費カロリー: 104.68 kcal
走行距離: 1.02 km
activityMinutes: 26・activitySeconds: 26 を読み取り、アプリ側で 26 × 60 + 26 = 1586秒 に変換できた。runningDistanceAvailable: true となり、走行距離 1.02 km も正確に取得できた。
手順5:可用性チェックとフォールバック処理
Foundation Modelsのマルチモーダル機能はApple Intelligence対応デバイスかつiOS 27以降が必要だ。実際のアプリではデバイスや設定によって使えない場合を想定し、Vision.frameworkのOCR処理へフォールバックする実装が必要だ。
判定は3層で行う。
func analyzeImage(named imageName: String) async -> RingFitResult? {
// ① Attachment APIはiOS 27以降。それ以前のデバイスはVisionにフォールバック
guard #available(iOS 27, *) else {
return await fallbackToVisionOCR(named: imageName)
}
// ② Apple Intelligenceが無効またはモデル未ダウンロードの場合はフォールバック
// ※ isAvailableはVision用サブモデルの準備状態まで確認しないため、
// 実行時エラーはcatchで拾う
guard SystemLanguageModel.default.isAvailable else {
return await fallbackToVisionOCR(named: imageName)
}
guard let cgImage = UIImage(named: imageName)?.cgImage else {
return nil
}
// ③ Foundation Modelsで解析を試みる
do {
let session = LanguageModelSession()
let response = try await session.respond(
generating: RingFitResult.self
) {
"リングフィットアドベンチャーのリザルト画面です。各フィールドの値を取り出してください。"
Attachment(cgImage)
}
return response.content
} catch {
// Vision用サブモデル未ロードなど実行時エラー → Visionにフォールバック
print("Foundation Models failed, falling back to Vision: \(error)")
return await fallbackToVisionOCR(named: imageName)
}
}
// Vision.frameworkを使った既存のOCR処理
func fallbackToVisionOCR(named imageName: String) async -> RingFitResult? {
// 既存実装(NSEasyConnectのOCR処理)
return nil
}
各層の役割を整理すると以下の通りだ。
| 層 | 判定内容 | 対象ケース |
|---|---|---|
#available(iOS 27, *) |
Attachment APIの存在確認 |
iOS 26以前のデバイス |
isAvailable |
テキスト生成モデルの準備状態 | Apple Intelligence無効・未ダウンロード |
catch |
実行時エラーの補足 | Vision用サブモデル未ロードなど |
action1() と action2() もこの関数を呼び出す形にリファクタリングすると、重複コードをまとめられる。
func action1() {
Task {
if let result = await analyzeImage(named: "rfa1") {
let totalSeconds = result.activityMinutes * 60 + result.activitySeconds
let distance: Double? = result.runningDistanceAvailable ? result.runningDistanceKm : nil
text = """
活動時間(秒): \(totalSeconds)
消費カロリー: \(result.caloriesBurned) kcal
走行距離: \(distance.map { "\($0) km" } ?? "なし")
"""
}
}
}
手順1〜5のソースコード全文
import SwiftUI
import FoundationModels
@Generable
struct RingFitResult {
@Guide(description: "活動時間の「分」の部分のみを整数で(例:'13分11秒'なら13)")
var activityMinutes: Int
@Guide(description: "活動時間の「秒」の部分のみを整数で、0〜59の範囲(例:'13分11秒'なら11)")
var activitySeconds: Int
@Guide(description: "合計消費カロリーをkcal単位の小数で(例:29.48)")
var caloriesBurned: Double
@Guide(description: "走行距離が数値で表示されていればtrue、'-'または表示なしならfalse")
var runningDistanceAvailable: Bool
@Guide(description: "走行距離をkm単位の小数で。runningDistanceAvailableがtrueのときのみ有効")
var runningDistanceKm: Double
}
struct ContentView: View {
@State private var text: String = ""
var body: some View {
ScrollView {
VStack(spacing: 16) {
Text(text)
.frame(maxWidth: .infinity, alignment: .leading)
.padding()
Button("画像1を解析", action: action1)
Button("画像2を解析", action: action2)
}
}
}
func action1() {
Task {
if let result = await analyzeImage(named: "rfa1") {
let totalSeconds = result.activityMinutes * 60 + result.activitySeconds
let distance: Double? = result.runningDistanceAvailable ? result.runningDistanceKm : nil
text = """
活動時間(秒): \(totalSeconds)
消費カロリー: \(result.caloriesBurned) kcal
走行距離: \(distance.map { "\($0) km" } ?? "なし")
"""
}
}
}
func action2() {
Task {
if let result = await analyzeImage(named: "rfa2") {
let totalSeconds = result.activityMinutes * 60 + result.activitySeconds
let distance: Double? = result.runningDistanceAvailable ? result.runningDistanceKm : nil
text = """
活動時間(秒): \(totalSeconds)
消費カロリー: \(result.caloriesBurned) kcal
走行距離: \(distance.map { "\($0) km" } ?? "なし")
"""
}
}
}
func analyzeImage(named imageName: String) async -> RingFitResult? {
guard #available(iOS 27, *) else {
return await fallbackToVisionOCR(named: imageName)
}
guard SystemLanguageModel.default.isAvailable else {
return await fallbackToVisionOCR(named: imageName)
}
guard let cgImage = UIImage(named: imageName)?.cgImage else {
return nil
}
do {
let session = LanguageModelSession()
let response = try await session.respond(
generating: RingFitResult.self
) {
"リングフィットアドベンチャーのリザルト画面です。各フィールドの値を取り出してください。"
Attachment(cgImage)
}
return response.content
} catch {
print("Foundation Models failed, falling back to Vision: \(error)")
return await fallbackToVisionOCR(named: imageName)
}
}
func fallbackToVisionOCR(named imageName: String) async -> RingFitResult? {
// 既存実装(NSEasyConnectのOCR処理)
return nil
}
}
Vision.frameworkとの比較
NSEasyConnectでは同様の処理をVision.frameworkのテキスト認識で実装していた。2つのアプローチの違いを整理する。
| Vision.framework (OCR) | Foundation Models(マルチモーダル) | |
|---|---|---|
| 実装コスト | テキスト認識→数値変換→補正ロジックが必要 | @Generable 構造体の定義のみ |
| 時間の変換 | 「13分11秒」→秒数の変換ロジックを自前実装 | 分・秒を別プロパティで読み取り、演算はアプリ側で実行 |
| 誤認識への対処 | 1と7の混同など補正ヒューリスティックが必要 | コンテキスト理解で誤認識が起きにくい |
| optional対応 | 走行距離フィールドの有無を自前で判定 | Bool + Double に分離することで安定生成 |
| 動作環境 | 全デバイス・オフライン | Apple Intelligence対応デバイスが必要 |
| 処理速度 | 高速 | 数秒かかる |
Vision.frameworkは全デバイスで高速に動作する一方、認識したテキストを数値に変換・補正する処理を自前で実装する必要がある。Foundation Modelsのマルチモーダル機能はApple Intelligence対応デバイスに限られるものの、構造体を定義するだけで構造化データを取り出せるのが魅力だ。
まとめ
Foundation Modelsのマルチモーダル機能と @Generable を組み合わせることで、リングフィットアドベンチャーのリザルト画面から運動記録を構造化データとして取り出せた。
期待通りの結果を得るまでに @Generable の設計を1度見直した。得られた知見を以下にまとめる。
- 読み取りと演算は分離した方が安定する。活動時間を秒数に変換させようとしたところ誤読と演算ミスが重なった。モデルには分・秒の読み取りだけを任せ、演算はアプリ側で行う設計がよい
Optional<Double>よりBool + Doubleのペアが安定する。nilを生成させるとモデルが-1.0や0.0で代替しようとする。存在判定をBoolに分離することで安定した結果が得られた@Guideのdescriptionは日本語で記述しても同等の精度が得られた。コードの可読性を優先する場合は日本語で記述してよい- Vision.framework OCR と比べて実装コストを大幅に削減できた。誤認識補正のヒューリスティックも不要になった
このレベルの画像解析であれば iPhone 16e の 3B モデルでも十分に対応できることがわかった。Apple Intelligence非対応デバイスへの対応が不要なユースケースであれば、Foundation Modelsのマルチモーダル機能は有力な選択肢になると感じた。同じような検証をしてみたい方の参考になれば幸いだ。










