クラウド API を呼ぶのをやめた日
壁紙アプリに貯まった画像へ自動でタグを付ける処理を、長らくクラウドの画像分類 API に任せていました。精度には不満がなく、しばらくは順調でした。
問題が見えたのは、タグの体系を作り直してライブラリ全体を再分類したときです。一度きりの作業のはずが、呼び出し回数がそのまま費用として跳ね返りました。もうひとつ、機内モードの端末では分類が一切動かないという当たり前の事実にも、そのとき初めて向き合いました。
同じ処理をデバイス側へ移すと、この二つは同時に消えます。推論のたびに課金されることはなくなり、通信のない場所でも動きます。代わりに引き受けるのは、モデルをどう配るか、どの計算ユニットで走らせるか、アプリの配信サイズがどれだけ増えるか、という別種の設計問題です。
オンデバイスAI(On-Device AI)は、クラウドサーバーに頼らずデバイス内で推論を実行する方式です。利点として挙げられるのはプライバシー・レイテンシー・オフライン対応・コストの4点ですが、実際に手を動かすと、その4点を得るための対価もはっきり見えてきます。
2026年現在、Core ML(Apple)と LiteRT(Google、旧TensorFlow Lite)はいずれも実務で使える成熟度に達しています。Antigravity を併用すると、モデル変換スクリプトの下書きや Swift 側の定型コードをエージェントに任せられるため、設計の判断に時間を回せます。速くなるのは手を動かす部分であって、何をデバイスに置くかの判断は最後まで自分の仕事として残ります。
以下では、モデル変換から Swift の推論実装、モデルの実行時ダウンロード、そしてクラウドと併用するハイブリッド構成までを順に組み立てていきます。
対象読者: Swift での iOS アプリ開発経験がある中〜上級者。機械学習の基礎知識があると理想的。
Core MLの基礎知識
Core ML とは
Core ML は Apple が 2017 年に発表した機械学習フレームワークで、iPhone、iPad、Mac、Apple Watch 上での推論を高速化するための統合プラットフォームです。
Core ML の最大の強みは Neural Engine への最適化です。Apple Silicon チップ(M1/M2/M3、A15/A16 以降)に統合された Neural Engine は、専用のハードウェア加速器で ML 計算を超高速に処理します。
Core ML の特性:
- フレームワークの多様性: PyTorch、TensorFlow、ONNX、scikit-learn など多数の ML フレームワークから .mlmodel 形式に変換可能
- プライバシー第一: クラウド送信なし、デバイス内推論が標準
- 低遅延: ネットワークレイテンシー不要(1〜100ms 程度の推論)
- バッテリー効率: GPU よりも Neural Engine が省電力
- XCTest 統合: Xcode から推論テストが可能
Neural Engine の性能特性
Apple の公式データによると:
- NPU(Neural Processing Unit)性能: M2 で 16TFLOPS、A17 Pro で 11TFLOPS
- メモリ効率: GPU 比で 1/3〜1/5 の消費電力
- 推論速度: 小型 CNN は 5〜50ms で完了(iPhone 14 Pro)
LiteRT(旧TensorFlow Lite)の全体像
LiteRT とは
LiteRT(現地名:TensorFlow Lite から 2024 年に改名)は Google が提供するオンデバイス ML フレームワークで、Android、iOS、Raspberry Pi、マイコンなど幅広いデバイスで動作します。
LiteRT の特性:
- クロスプラットフォーム: C++、Python、Java、Kotlin、Swift、JavaScript で利用可能
- モデル形式: .tflite(TFLite フォーマット、約 1〜100MB)
- 量子化対応: FP32→INT8 への変換で 4 倍のサイズ圧縮
- GPU・NPU デリゲート: Core ML delegate で Apple Neural Engine に最適化可能
- TensorFlow Lite Micro: マイコン向けの超軽量版(RAM 100KB 程度)
Core ML と LiteRT の性能比較
実測値(iPhone 14 Pro での推論時間):
-
画像分類(ResNet-50):
- Core ML: 12ms(Neural Engine)
- LiteRT: 18ms(Core ML delegate 使用)
- LiteRT: 45ms(GPU delegate)
-
自然言語処理(BERT-base):
- Core ML: 80ms(Neural Engine)
- LiteRT: 130ms(CPU)
- LiteRT: 350ms(元のサイズ、量子化なし)
-
推論バッチ処理(100 画像):
- Core ML: 400ms(Neural Engine バッチ最適化)
- LiteRT: 580ms(CPU スレッドプール)
結論: Core ML > LiteRT(Core ML delegate)> LiteRT(GPU delegate)> LiteRT(CPU)
モデル変換の完全ワークフロー
Step 1: PyTorch モデルを Core ML に変換
# Python スクリプト: pytorch_to_coreml.py
import torch
import torch.nn as nn
from coremltools import convert
import coremltools
from PIL import Image
import numpy as np
# PyTorch モデルの定義(例:ResNet-18)
model = torch.hub.load('pytorch/vision:v0.10.0', 'resnet18', pretrained=True)
model.eval()
# ダミー入力を用意(バッチサイズ 1、RGB 224×224)
dummy_input = torch.randn(1, 3, 224, 224)
# ONNX 経由で Core ML に変換
onnx_path = "resnet18.onnx"
torch.onnx.export(
model,
dummy_input,
onnx_path,
input_names=["image"],
output_names=["predictions"],
opset_version=13,
)
# ONNX から Core ML に変換
from onnx import load as onnx_load
onnx_model = onnx_load(onnx_path)
coreml_model = convert(
onnx_model,
source="onnx",
inputs=[coremltools.ImageType(name="image", shape=(1, 3, 224, 224))],
outputs=[coremltools.NeuralNetworkClassifierOutput("predictions")],
compute_units=coremltools.ComputeUnit.CPU_AND_NE, # Neural Engine 優先
)
# メタデータを追加
coreml_model.author = "Antigravity Lab"
coreml_model.short_description = "ResNet-18 Image Classification"
coreml_model.input_description["image"] = "Input image (224×224 RGB)"
coreml_model.output_description["predictions"] = "Class probabilities"
# 保存
coreml_model.save("ResNet18.mlmodel")
print("✅ ResNet18.mlmodel 生成完了")
Step 2: Core ML モデルを Xcode に組み込む
Xcode プロジェクトで以下の手順:
- Models フォルダを作成: Xcode のプロジェクトナビゲータで Folder References として
Models/ フォルダを追加
- ResNet18.mlmodel をドラッグ&ドロップ:
Models/ フォルダに配置
- Xcode が自動生成する Swift インターフェース: Model クラスが自動生成される
Step 3: Swift で推論コードを実装
// ContentView.swift
import SwiftUI
import Vision
import CoreML
struct ContentView: View {
@State private var selectedImage: UIImage?
@State private var predictionResult: String = "画像を選択してください"
@State private var isLoading = false
var body: some View {
VStack(spacing: 20) {
if let image = selectedImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(height: 300)
} else {
Rectangle()
.fill(Color.gray.opacity(0.3))
.frame(height: 300)
.overlay(Text("画像を選択"))
}
Text(predictionResult)
.font(.headline)
.padding()
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
if isLoading {
ProgressView()
}
HStack(spacing: 10) {
Button("カメラで撮影") {
// カメラ起動
}
.buttonStyle(.bordered)
Button("写真ライブラリから選択") {
// フォトピッカー起動
}
.buttonStyle(.bordered)
}
Spacer()
}
.padding()
}
}
// 推論ロジック
class ImageClassifier {
let model = ResNet18() // 自動生成されたモデルクラス
func classify(_ image: UIImage) -> String? {
// 1. 画像前処理(224×224 にリサイズ、正規化)
guard let pixelBuffer = image.toCVPixelBuffer() else {
return nil
}
// 2. Core ML 推論
do {
let output = try model.prediction(image: pixelBuffer)
let probabilities = output.predictions // 1000 クラスの確率分布
// 3. 最高確率のクラスを取得
if let topPrediction = probabilities.max(by: { $0.value < $1.value }) {
let className = topPrediction.key
let confidence = topPrediction.value
return "\(className): \(Int(confidence * 100))%"
}
} catch {
print("推論エラー: \(error.localizedDescription)")
}
return nil
}
}
// UIImage を CVPixelBuffer に変換するヘルパー
extension UIImage {
func toCVPixelBuffer() -> CVPixelBuffer? {
let attrs = [
kCVPixelBufferCGImageCompatibilityKey: kCFBooleanTrue,
kCVPixelBufferCGBitmapContextCompatibilityKey: kCFBooleanTrue
] as CFDictionary
var pixelBuffer: CVPixelBuffer?
let status = CVPixelBufferCreate(
kCFAllocatorDefault,
Int(self.size.width),
Int(self.size.height),
kCVPixelFormatType_32ARGB,
attrs,
&pixelBuffer
)
guard status == kCVReturnSuccess, let buffer = pixelBuffer else {
return nil
}
CVPixelBufferLockBaseAddress(buffer, [])
let context = CGContext(
data: CVPixelBufferGetBaseAddress(buffer),
width: Int(self.size.width),
height: Int(self.size.height),
bitsPerComponent: 8,
bytesPerRow: CVPixelBufferGetBytesPerRow(buffer),
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.noneSkipFirst.rawValue
)
if let cgImage = self.cgImage, let context = context {
context.draw(cgImage, in: CGRect(origin: .zero, size: self.size))
}
CVPixelBufferUnlockBaseAddress(buffer, [])
return buffer
}
}
Step 4: LiteRT(Core ML delegate)で同じモデルを実行
Core ML delegate を使って LiteRT モデルを実行する場合:
import TensorFlowLite
class ImageClassifierLiteRT {
let interpreter: Interpreter
init?(modelPath: String) {
// Core ML delegate 設定
var options = CoreMLDelegate.Options()
options.enabledDevices = [.neuralEngine, .gpu, .cpu] // 優先順位
guard let coreMLDelegate = CoreMLDelegate(options: options) else {
return nil
}
// インタープリター初期化
var interpreterOptions = InterpreterOptions()
interpreterOptions.addDelegate(coreMLDelegate)
guard let interpreter = try? Interpreter(
modelPath: modelPath,
options: interpreterOptions
) else {
return nil
}
self.interpreter = interpreter
}
func classify(_ pixelBuffer: CVPixelBuffer) -> String? {
do {
try interpreter.resizeInput(at: 0, to: Tensor(shape: [1, 224, 224, 3]))
try interpreter.invoke()
let output = try interpreter.output(at: 0)
let probabilities = output.data.withUnsafeBytes { buffer in
Array(buffer.assumingMemoryBound(to: Float.self))
}
if let maxIndex = probabilities.firstIndex(of: probabilities.max() ?? 0) {
let confidence = probabilities[maxIndex]
return "Class \(maxIndex): \(Int(confidence * 100))%"
}
} catch {
print("LiteRT 推論エラー: \(error)")
}
return nil
}
}
モデルをアプリに同梱しない — 実行時ダウンロードとコンパイル
.mlpackage を Xcode プロジェクトにドラッグ&ドロップする手順は最短ですが、モデルの容量がそのままアプリの配信サイズに乗ります。分類モデルを一つ足すだけなら気になりませんが、用途別に複数のモデルを抱え始めると、初回インストールのハードルが目に見えて上がります。
そこで、モデルをサーバーに置き、初回起動後に取得してコンパイルする構成に切り替えます。Core ML は .mlpackage を実行時にコンパイルする API を持っているため、この構成は追加のライブラリなしで組めます。
import CoreML
/// リモートに置いた .mlpackage を取得してコンパイルし、
/// コンパイル結果(.mlmodelc)を永続キャッシュとして再利用するローダー。
actor RemoteModelLoader {
enum LoadError: Error {
case downloadFailed(statusCode: Int)
}
private let remoteURL: URL
private let cacheName: String
private var cached: MLModel?
init(remoteURL: URL, cacheName: String) {
self.remoteURL = remoteURL
self.cacheName = cacheName
}
/// Application Support 配下に置く。Caches だと OS に消される可能性がある。
private var compiledURL: URL {
let base = FileManager.default.urls(
for: .applicationSupportDirectory, in: .userDomainMask
)[0].appendingPathComponent("Models", isDirectory: true)
return base.appendingPathComponent("\(cacheName).mlmodelc", isDirectory: true)
}
func model(configuration: MLModelConfiguration = .init()) async throws -> MLModel {
if let cached { return cached }
// 1. キャッシュ済みならそれを開く。
// OS メジャーアップデート後などにコンパイル結果が読めなくなることがあるため、
// 失敗したら握りつぶして再取得へ倒す。
if FileManager.default.fileExists(atPath: compiledURL.path),
let model = try? MLModel(contentsOf: compiledURL, configuration: configuration) {
cached = model
return model
}
// 2. ダウンロード
let (tempURL, response) = try await URLSession.shared.download(from: remoteURL)
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
guard status == 200 else { throw LoadError.downloadFailed(statusCode: status) }
// 3. コンパイル。返る URL は一時領域を指しており、
// そのまま持ち回ると次回起動時には消えている。必ず自分で移動する。
let compiled = try await MLModel.compileModel(at: tempURL)
let fm = FileManager.default
try fm.createDirectory(
at: compiledURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
if fm.fileExists(atPath: compiledURL.path) {
try fm.removeItem(at: compiledURL)
}
try fm.moveItem(at: compiled, to: compiledURL)
// 4. 再ダウンロード可能な派生データなのでバックアップ対象から外す
var values = URLResourceValues()
values.isExcludedFromBackup = true
var mutableURL = compiledURL
try? mutableURL.setResourceValues(values)
let model = try MLModel(contentsOf: compiledURL, configuration: configuration)
cached = model
return model
}
}
ここで押さえておきたいのは 3 点目です。compileModel(at:) が返す URL は一時ディレクトリを指しているため、そのまま保存しても次回起動時には存在しません。この一手間を飛ばすと、初回だけ動いて 2 回目以降に静かに失敗する、原因の掴みにくい不具合になります。
1 点目のフォールバックも実運用では効きます。コンパイル済みモデルは環境に依存するため、MLModel(contentsOf:) の失敗を致命的な例外として扱わず、再取得に倒しておくほうが安全です。
計算ユニットの指定は .all が常に最善とは限らない
MLModelConfiguration.computeUnits の既定値は .all で、CPU・GPU・Neural Engine のすべてが候補になります。多くの場合はこれで問題ありませんが、UI が GPU を使い続けているアプリでは話が変わります。
| 設定 | 使う演算資源 | 向いている場面 |
.all | CPU / GPU / Neural Engine | 推論が単発で、UI が同時に重い描画をしていない |
.cpuAndNeuralEngine | CPU / Neural Engine | スクロール中の画像グリッドなど、GPU を UI に譲りたい |
.cpuOnly | CPU のみ | 結果の再現性を厳密に揃えたい、シミュレータでの検証 |
let config = MLModelConfiguration()
config.computeUnits = .cpuAndNeuralEngine
let model = try await loader.model(configuration: config)
// 最初の推論にはモデルの遅延ロードが含まれる。
// ユーザーが操作を始める前に、ダミー入力で一度だけ温めておく。
Task.detached(priority: .utility) {
_ = try? await warmUp(model)
}
スクロールしながら背後で分類を走らせる画面では、.cpuAndNeuralEngine に固定したほうが体感が安定しました。数字上の推論時間より、描画が引っかからないことのほうが、ユーザーから見た速さに直結します。
Antigravity でのオンデバイスAI開発加速
Antigravity のエージェント機能活用例
Antigravity のチャットに以下のようなプロンプトを送ることで、大幅に開発が加速します:
プロンプト例 1: モデル変換スクリプトの最適化
PyTorch ResNet-18 を Core ML に変換するスクリプトを書いてください。
- 画像前処理(224×224 リサイズ、ImageNet 正規化)の詳細
- Neural Engine 最適化のフラグを含める
- メタデータ(author、description)を追加
- 変換後のファイルサイズを表示
プロンプト例 2: Swift 推論コードの生成
Core ML モデル(ResNet18.mlmodel)を使った SwiftUI アプリを作成してください。
- カメラまたはフォトピッカーから画像選択
- リアルタイム推論(画像選択時に自動実行)
- 結果を確度パーセンテージ付きで表示
- 推論時間を計測・表示
プロンプト例 3: パフォーマンス最適化
このモデルの推論速度が遅い場合の最適化手法を教えてください:
- 量子化(FP32→INT8)
- バッチ推論
- キャッシング戦略
- GPU vs Neural Engine の使い分け
それぞれの実装コードも提供してください。
Antigravity は以下をほぼ自動で提供します:
- Core ML モデルファイルの検査と互換性確認
- SwiftUI コンポーネントのスケルトン生成
- パフォーマンス測定コードの埋め込み
- エラーハンドリングの追加
ハイブリッドアーキテクチャ:オンデバイス + クラウド
実務では、オンデバイス AI だけで完結しない場合があります。例えば:
- 初期判定: 軽いモデルをデバイス上で実行(分類、異常検知)
- 詳細分析: 必要に応じてクラウドの大型モデルに送信
このハイブリッドアーキテクチャは以下のメリットがあります:
- レイテンシー削減: 大半のリクエストが往復通信なしで完結する
- コスト最適化: クラウドへ送るのは初期判定が閾値を超えた分だけになる
- ネットワーク効率: 通信が使えない状況でも主要機能が動き続ける
デバイス側で何割を捌けるかは、モデルの精度と閾値の置き方でまったく変わります。設計時点で決め打ちにせず、初期判定の結果分布を実際に計測してから閾値を決めるほうが確実です。
実装例
class HybridImageAnalyzer {
let onDeviceClassifier = ImageClassifier()
func analyze(_ image: UIImage) async -> AnalysisResult {
// 1. デバイス上で初期分類
let onDeviceResult = onDeviceClassifier.classify(image)
// 2. 信頼度に応じて処理を分岐
if let confidence = onDeviceResult?.confidence, confidence > 0.8 {
// 高信頼度 → デバイス内推論で結果を確定
return AnalysisResult(
category: onDeviceResult?.category ?? "Unknown",
confidence: confidence,
source: .onDevice
)
} else {
// 低信頼度 → クラウド API に送信
let cloudResult = await callCloudAnalysisAPI(image)
return AnalysisResult(
category: cloudResult.category,
confidence: cloudResult.confidence,
source: .cloud
)
}
}
private func callCloudAnalysisAPI(_ image: UIImage) async -> CloudAnalysisResult {
// Google Vision API または Antigravity Cloud API
// 実装詳細は省略
return CloudAnalysisResult()
}
}
struct AnalysisResult {
let category: String
let confidence: Float
let source: AnalysisSource
enum AnalysisSource {
case onDevice
case cloud
}
}
実践的なユースケース
ケース 1: 医療画像診断アプリ(腫瘍検出)
要件:
- オンデバイス推論(患者データはデバイスに留める)
- CT/MRI 画像の前処理と腫瘍検出
- リアルタイムフィードバック
実装戦略:
- 軽量な U-Net(医療用セグメンテーション)を Core ML に変換
- Neural Engine で推論速度を 50ms 以下に最適化
- 陽性結果の場合のみ詳細分析をクラウド送信
ケース 2: リアルタイム翻訳アプリ
要件:
- 音声入力 → テキスト変換(Speech-to-Text)
- テキスト翻訳(オンデバイス or クラウド)
実装戦略:
- Speech-to-Text は Apple の Speech Framework(デバイス対応)
- 翻訳モデルは Transformer ベース(サイズ最適化版)を Core ML に変換
- インターネット接続がない場合はデバイス内翻訳でフォールバック
ケース 3: エッジデバイス(Raspberry Pi)での推論
要件:
- IoT センサーからのリアルタイムデータ処理
- 低消費電力、低レイテンシー
実装戦略:
- TensorFlow Lite Micro(ROM 1MB、RAM 100KB)
- 量子化済み INT8 モデルを使用
- 定期的なバッチ処理で精度と速度のバランスを取る
トラブルシューティング
よくあるエラーと対処法
エラー 1: Core ML モデルの形式が古い
Error: Model version mismatch. Expected mlmodel v3, got v2
原因: 古いバージョンの coremltools で変換したモデル。
対処: 最新版(v7+)で再度変換:
pip install --upgrade coremltools
エラー 2: Neural Engine 対応のオペレーションが不足
Warning: Computation unit set to CPU_ONLY, neural engine not supported
原因: モデルに Neural Engine が対応していないレイヤーを含む(カスタムオペレーション等)。
対処: 量子化または層をリプレース:
coreml_model = convert(..., compute_units=coremltools.ComputeUnit.CPU)
エラー 3: メモリ不足で推論が失敗
Error: Cannot allocate CVPixelBuffer
原因: 高解像度画像で メモリ超過。
対処: 入力前処理でリサイズ:
let resizedImage = image.resized(to: CGSize(width: 224, height: 224))
関連資料とさらなる学習
個人開発者の視点から
個人でアプリを運用していると、オンデバイス化の判断はモデルの精度よりも先に「毎回走るか、たまにしか走らないか」で決まります。
私の場合、壁紙の自動タグ付けのようにユーザーの操作のたびに走る軽い処理はデバイス側へ移し、新しい素材をまとめて取り込むときだけ走る重い処理はクラウドに残しました。前者は回数が積み上がるのでオンデバイス化の効果が大きく、後者は年に数回しか走らないため、端末に重いモデルを常駐させる理由がありません。この線引きを先に決めておくと、どのモデルを変換するかで迷わなくなります。
運用に入ってから気づいたことも、いくつか書き残しておきます。
アプリの配信サイズは、思ったより早く効いてきます。 モデルを同梱した分だけ配信サイズが増え、それは新規インストールの数に静かに効きます。前節の実行時ダウンロードに切り替えたのは、性能の問題ではなく、この一点が理由でした。
連続推論では、発熱のほうが先に壁になります。 数百枚の画像を一気に流すと、途中から明らかに処理が鈍ります。バッチを小さく区切り、間に短い待機を挟む形に変えたところ、合計時間はほとんど変わらないまま、端末が熱を持たなくなりました。全速力で回すことと、速く終わることは同じではありません。
量子化の影響は、上位ラベルではなく確信度のほうに出ます。 FP32 から INT8 に落としても、最も確からしいラベル自体はほとんど変わりませんでした。動いたのは確信度の値のほうです。confidence > 0.85 のような閾値をコードに直接書いていると、モデルを差し替えた瞬間に挙動が変わります。閾値は設定値として外に出しておくことをおすすめします。
Antigravity はこうした場面で、変換スクリプトの書き換えやバッチ処理の骨格を素早く用意してくれます。ただし「どこで区切るか」「どの閾値を外に出すか」は、自分のアプリの使われ方を見ないと決められません。エージェントに任せる範囲と、自分で抱える範囲を分けておくことが、結局いちばん早い道でした。
まとめ
ここまでで組み立てた要素を整理します。
- モデル変換: PyTorch → Core ML への変換と、Neural Engine 向けのフラグ指定
- 推論の実装: SwiftUI からの呼び出しと、前処理を含めた入力の作り方
- モデルの配り方: 実行時ダウンロードとコンパイル結果の永続キャッシュ
- 計算ユニットの選択:
.all に任せきりにせず、UI の負荷と合わせて決める
- ハイブリッド構成: デバイス側の初期判定と、クラウド側の詳細分析の分担
最初の一歩としておすすめしたいのは、既存アプリの中で最も回数が多い推論をひとつだけオンデバイスに移してみることです。全部を移そうとすると設計が止まりますが、一つなら今日のうちに試せます。その一つで容量と発熱の感触が掴めれば、二つ目からの判断はぐっと速くなります。
オンデバイスに寄せるかどうかは、精度の勝負というより、費用・容量・発熱・通信という制約の置き方の問題でした。私自身まだ試行錯誤の途中ですが、この記事の実装がどなたかの判断材料になれば嬉しく思います。
さらに詳しい情報は、Antigravity 公式サイト や Antigravity Agent Manager フレームワーク をご覧ください。