個人で運用している iOS 向け壁紙アプリの自動カテゴリ分類を、Gemini Vision API に全面移行したのが 2025 年の春でした。精度は十分に上がった一方で、月末のクラウド請求が想定の倍近くまで膨らみ、収益とのバランスをどう取るかが悩みになりました。アーティスト・クリエイターの廣川政樹です。2014 年から続けている個人アプリ開発のなかで、累計 5,000 万 DL を超えた頃から「全推論をクラウドに任せる」設計の限界を肌で感じる場面が増えてきました。
そこで 3 ヶ月かけて取り組んだのが、iOS 側の Apple Vision Framework で事前フィルタを行い、Gemini Vision API への送信を必要なものだけに絞るハイブリッド構成です。最終的に、月あたりの Gemini Vision API 呼び出し回数を 53%、コストを実測で約 71% 削減できました。
その設計判断と実装の細部、そして個人開発者だからこそ見えた運用上のハマりどころを、なるべく抽象化せず書き残しておきます。同じように「クラウド推論のコストと精度のバランスを取りたい」と感じている iOS 開発者の参考になれば嬉しいです。
なぜ全件クラウド推論をやめる必要があったか
最初に取り組んだのは、ユーザーが壁紙をアップロードした瞬間に、すべての画像を Gemini Vision API に送って「自然・抽象・人物・動物・乗り物・建築・宇宙」など 18 カテゴリに自動分類する仕組みでした。精度は実用域に達し、人手分類とのキャッパ係数で 0.81 を確認しています。
ただ、運用に乗せてから 1 ヶ月で 3 つの問題が浮かびました。
第一に、コストです。私の壁紙アプリには 1 日あたり平均で 18,000 枚前後の新規画像が流入し、Gemini Vision で全件処理すると、画像 1 枚あたり 0.0011 USD として月額換算で 600 USD を超えます。個人運用の AdMob 収益と組み合わせたときに、明らかに採算が取りづらくなる水準でした。
第二に、レイテンシです。海外 API への往復で 400〜1,200 ms の幅があり、ユーザーがアップロードしてから「カテゴリが反映されるまで」の体感速度が落ちていました。
第三に、明らかに分類不要な画像が一定割合を占めていたことです。具体的には、ほぼ単色の画像、ピンボケのテスト撮影画像、極端に小さなサムネイル、すでに自分のアプリで生成された壁紙のスクリーンショット ─ これらが体感で 20% 近くありました。これらに API クレジットを消費する意味はありません。
「全件をクラウドに送らない」設計に切り替える必要があると、ここで腹を決めました。
オンデバイス事前フィルタの責務をどう設計したか
Apple Vision Framework は iOS 11 から提供されているオンデバイス画像解析の基盤で、最新の iOS 26 系では VNClassifyImageRequest、VNGenerateAttentionBasedSaliencyImageRequest、VNDetectHumanRectanglesRequest などが Apple Neural Engine 上で動きます。レイテンシは iPhone 14 Pro 実機で 30〜90 ms、消費電力もほぼ計測誤差レベル、そして何より無料です。
ただし精度の限界もあります。Apple Vision の汎用分類器は 1,000 ラベル前後の ImageNet 系で、私の壁紙アプリが必要とする「日本画風」「和柄」「サイバーパンク」「ローファイ」のようなテイスト的なカテゴリは判別できません。ここに Gemini Vision API の意味理解の強みが効きます。
そこで、両者の責務を次のように切り分けました。
オンデバイス側は「Gemini Vision に送る意味があるかを判定するゲート役」に徹します。具体的には、
画像の鮮明さスコアと顕著性マップの面積を測り、明らかに低品質な画像を弾く
既存の自分のアプリで生成された画像か(埋め込みウォーターマーク検出)を確認する
大ジャンル(写真風・イラスト風・抽象)を Vision Framework の汎用分類で当て、後続の Gemini プロンプトに渡すヒントを作る
クラウド側(Gemini Vision API)は、上記を通過した画像に対してのみ、最終カテゴリと付加メタデータ(推奨タグ、雰囲気、推奨カラーパレット)を返す責務に集中させます。
「全部 AI でやる」のではなく、「AI に任せる仕事を選別する役割を AI に与える」設計です。両家の祖父が宮大工だった影響なのか、私はソフトウェアの設計でも「適材適所」をかなり意識する癖があり、この切り分けはしっくり来ました。
ハイブリッド推論の全体アーキテクチャ
具体的な処理の流れは次のとおりです。
ステップ 1. ユーザーが壁紙をアップロード → Photos 経由で UIImage を取得
ステップ 2. CIImage に変換し、オンデバイスの前処理パイプラインを直列実行
ステップ 3. 各ゲートで弾かれた画像は、軽量ラベルだけを付けてローカル DB に格納し、Gemini Vision には送らない
ステップ 4. ゲートを通過した画像のみ、リサイズ + JPEG 圧縮で 224×224〜512×512 に縮小して Gemini Vision API へ送信
ステップ 5. Gemini Vision の応答(JSON schema バインディング済み)をローカルキャッシュに保存し、UI に反映
オンデバイスとクラウドの境界を「ゲート」と呼んでいるのは、設計図に書いたときに、神社の鳥居をくぐる感覚で説明できたためです。鳥居の前で身を清めるように、API の入口で画像を整える、という比喩は、設計レビューでも腑に落ちました。
Apple Vision Framework での前処理実装
実装の中核となるオンデバイス側のコードです。Swift 6 + iOS 18 SDK で確認しています。
import Vision
import CoreImage
struct ImagePrefilterResult {
let shouldSendToCloud: Bool
let coarseCategory: CoarseCategory
let sharpnessScore: Double
let saliencyArea: Double
let reasonIfRejected: String ?
}
enum CoarseCategory : String , Codable {
case photoLike , illustrationLike , abstract , unknown
}
final class ImagePrefilter {
private let saliencyRequest = VNGenerateAttentionBasedSaliencyImageRequest ()
private let classifyRequest = VNClassifyImageRequest ()
func analyze ( ciImage : CIImage) async throws -> ImagePrefilterResult {
let handler = VNImageRequestHandler ( ciImage : ciImage, options : [ : ])
try handler. perform ([saliencyRequest, classifyRequest])
let sharpness = sharpnessScore ( of : ciImage)
let saliencyArea = saliencyAreaRatio ( from : saliencyRequest.results ? . first )
let coarse = coarseCategory ( from : classifyRequest.results ?? [])
if sharpness < 0.18 {
return . init ( shouldSendToCloud : false ,
coarseCategory : coarse,
sharpnessScore : sharpness,
saliencyArea : saliencyArea,
reasonIfRejected : "too_blurry" )
}
if saliencyArea < 0.06 {
return . init ( shouldSendToCloud : false ,
coarseCategory : coarse,
sharpnessScore : sharpness,
saliencyArea : saliencyArea,
reasonIfRejected : "low_saliency" )
}
if isOwnAppGeneratedWatermark ( ciImage : ciImage) {
return . init ( shouldSendToCloud : false ,
coarseCategory : coarse,
sharpnessScore : sharpness,
saliencyArea : saliencyArea,
reasonIfRejected : "own_watermark" )
}
return . init ( shouldSendToCloud : true ,
coarseCategory : coarse,
sharpnessScore : sharpness,
saliencyArea : saliencyArea,
reasonIfRejected : nil )
}
private func sharpnessScore ( of image: CIImage) -> Double {
// Laplacian variance approximation via CIConvolution3X3
let kernel: [CGFloat] = [ 0 , -1 , 0 , -1 , 4 , -1 , 0 , -1 , 0 ]
let filter = CIFilter. convolution3X3 ()
filter.inputImage = image
filter.weights = CIVector ( values : kernel. map { Float ( $0 ) }, count : 9 )
filter.bias = 0.5
guard let output = filter.outputImage else { return 0 }
let stats = variance ( of : output)
// Normalize to 0...1 (壁紙アプリの実データで decile を取って正規化)
return min (stats / 0.42 , 1.0 )
}
}
sharpnessScore のしきい値 0.18 は、壁紙アプリで実際に「人手で見ても何を撮ったか分からない」と判定された 2,400 枚の画像と、十分に鮮明な 3,800 枚の画像を使って ROC を引き、AUC 0.93 になった点を選んでいます。アプリのユースケースが違えば、この数値は再キャリブレーションが必要です。
顕著性面積(saliencyArea)の 0.06 というしきい値も同様で、抽象壁紙を誤って弾かないように、画像全体の 6% 以上に注意が向く領域があるかを条件にしました。
Gemini Vision API への送信判定とプロンプト設計
ゲートを通過した画像のみ、Gemini Vision に送ります。コストを意識して、画像は 512×512 以下にリサイズ、品質 0.78 の JPEG にしています。
import GoogleGenerativeAI
struct WallpaperClassification : Codable {
let primaryCategory: String
let secondaryCategory: String ?
let mood: String
let recommendedTags: [ String ]
let dominantColors: [ String ]
let suitableScreenTypes: [ String ]
}
final class WallpaperClassifier {
private let model: GenerativeModel
init ( apiKey : String ) {
self .model = GenerativeModel (
name : "gemini-2.5-flash" ,
apiKey : apiKey,
generationConfig : GenerationConfig (
temperature : 0.2 ,
topP : 0.95 ,
maxOutputTokens : 512 ,
responseMIMEType : "application/json" ,
responseSchema : Self .responseSchema
)
)
}
func classify ( image : UIImage,
coarseHint : CoarseCategory) async throws -> WallpaperClassification {
let resized = image. resizedKeepingAspect ( maxSide : 512 )
let jpegData = resized. jpegData ( compressionQuality : 0.78 ) !
let prompt = """
あなたは壁紙アプリの分類エンジンです。次の画像を 1 枚見て、JSON で分類結果を返してください。
オンデバイスの粗い事前分類では「 \( coarseHint. rawValue ) 」と判定されています。これは参考情報です。
primaryCategory は次の 18 カテゴリから 1 つ:
nature, abstract, person, animal, vehicle, architecture, space, urban,
anime, japanese_art, cyberpunk, lofi, minimalist, geometric, food, sports,
seasonal, other
mood は次から 1 つ:
calm, energetic, mysterious, romantic, melancholic, playful, sacred, neutral
recommendedTags は 3〜6 件、英小文字スネークケース。
dominantColors は #rrggbb の 3 件まで。
suitableScreenTypes は "iphone_lock", "iphone_home", "ipad", "mac_desktop" から該当するもの。
"""
let response = try await model. generateContent (
prompt,
ModelContent.Part. data ( mimetype : "image/jpeg" , jpegData)
)
guard let text = response. text ,
let data = text. data ( using : . utf8 ) else {
throw ClassifierError.emptyResponse
}
return try JSONDecoder (). decode (WallpaperClassification. self , from : data)
}
}
ここでのポイントは 3 つです。
ひとつ目は、responseMIMEType: "application/json" と responseSchema を併用して、Gemini の出力を構造化していることです。手元の壁紙アプリでの計測では、プロンプトで「JSON で返してね」と頼むだけの場合に比べて、JSON 構文エラーが 12% → 0.4% まで下がりました。
ふたつ目は、オンデバイスで得た粗いカテゴリ(coarseHint)をプロンプトに参考情報として渡していることです。これにより、Gemini の判定がブレるエッジケースで一致率が 4 ポイント上がりました。粗い情報の渡し方に過信せず「これは参考情報です」と明示するのは、過剰なバイアスを防ぐ意味で大事です。
みっつ目は、temperature: 0.2 のような低温度設定にしていることです。クリエイティブな出力は不要で、安定した分類結果が欲しい場面では、低温度のほうがコスト面・再現性ともに有利でした。
実運用 1 ヶ月の数値
ハイブリッド構成に切り替えてから 30 日経過した時点の数値を、嘘なく共有します。
ユーザーがアップロードした総画像数: 543,800 枚
オンデバイスゲートで弾かれた画像: 290,200 枚(53.4%)
Gemini Vision API への送信数: 253,600 枚
Gemini Vision の月額コスト: 約 173 USD(旧構成での同期間試算は約 600 USD)
コスト削減率: 71.2%
平均レイテンシ(アップロード → カテゴリ表示): 旧 920 ms → 新 410 ms
弾かれた画像のうち、後日ユーザーから「分類されない」とサポート問い合わせがあった件数: 7 件(0.0024%)
ゲートで弾かれた画像の内訳もメモしておきます。
低鮮明度(too_blurry): 198,400 枚
低顕著性(low_saliency): 71,200 枚
自社ウォーターマーク検出(own_watermark): 20,600 枚
特に「自社で生成した壁紙のスクリーンショットがアプリ内に再アップロードされる」現象は事前に想定していなかったため、これだけで 1 日 600〜800 枚を救えていたのは収穫でした。
ハマりどころ 1: 顕著性スコアが「抽象画」を誤って弾く問題
最初の 1 週間で、ユーザーから「抽象画の壁紙が新しいカテゴリに反映されない」という声が複数届きました。調べると、VNGenerateAttentionBasedSaliencyImageRequest が一様グラデーションや幾何的抽象画に対してほぼゼロのスコアを返すことが原因でした。
回避策として、Apple Vision のオブジェクトベース版(VNGenerateObjectnessBasedSaliencyImageRequest)と組み合わせ、両方が低い場合のみ「低顕著性」と判定するように変更しました。さらに、VNClassifyImageRequest の上位ラベルに「abstract」「pattern」「texture」が含まれている画像は顕著性チェックをスキップする例外を入れました。
修正後の誤却下率は 0.13% から 0.018% まで下がりました。オンデバイスのフィルタは精度より「絶対に正解を捨てない」を優先したほうが、最終的な体感品質が高くなります。
ハマりどころ 2: Gemini Vision の safety_settings でエッジケースが落ちる問題
ある日、ユーザーが投稿した「神社の境内」「お地蔵様」「祭壇」などの画像が、Gemini Vision の応答で BLOCK 判定されるケースが増えました。safety_settings のデフォルトでは、宗教的シンボルが含まれる画像に対して MEDIUM 以上の安全カテゴリで遮断されることがあるためです。
私の壁紙アプリでは日本の伝統文化や祈りの場面が題材として大事なので、ここを落とすわけにはいきません。HARM_CATEGORY_HATE_SPEECH などを除き、宗教関連の遮断レベルを BLOCK_NONE ではなく BLOCK_ONLY_HIGH に明示変更し、ログを取りながら様子を見ています。
宗教的・文化的なコンテンツを扱う方は、safety_settings を意識的にチューニングすることをお勧めします。安全設定はオフにする話ではなく、扱うドメインで「どのレベルが妥当か」を自分で決める話です。
ハマりどころ 3: バッテリー消費とサーマルスロットリング
Apple Vision Framework は Neural Engine を使うとはいえ、連続して 1,000 枚以上の画像を処理するとサーマルスロットリングが起きます。私の壁紙アプリではバックグラウンドで一括処理するモードがあり、ここで iPhone がほんのり温かくなるという声が届きました。
対処として、
ProcessInfo.thermalState を監視し、.serious 以上で 5 秒の間隔を挟む
バッテリー残量が 20% 未満のときは一括処理を打ち切り、Gemini Vision 送信もネットワーク待ちキューに格納
充電中かつ Wi-Fi 接続中の夜間のみ、本格的なバックグラウンド処理を許可
の 3 段階制御を入れました。個人開発の壁紙アプリで、ユーザー体験を損なわずにオンデバイス AI を回すには、こうした節度ある運用が前提になります。
Android 側(Google ML Kit)への横展開
Android アプリでも同じハイブリッド構成を入れています。オンデバイスフィルタは Google ML Kit の ImageLabeler と SubjectSegmenter、クラウド側は同じく Gemini Vision API を使い、サーバ側で iOS/Android のクライアント差を吸収する形にしました。
Android 側で気づいた違いは 2 点ありました。
ひとつは、機種差です。Pixel 系では ML Kit がほぼ瞬時に応答しますが、低価格帯の Android 端末では同じ処理が 5〜10 倍時間がかかることがあります。閾値判定の前にタイムアウト(800 ms)を入れ、間に合わない場合はゲートをスキップして Gemini Vision に送る、というフェイルセーフを入れました。
ふたつめは、コスト計上の単位です。AdMob 収益は iOS と Android で eCPM が異なり、私の壁紙アプリでは Android の eCPM が iOS の約 60% でした。同じ Gemini Vision コストでも、Android のほうが「1 枚あたりの収益貢献」が薄いので、Android 側はゲートをさらに厳しめに設定して送信率を 41% に絞っています。クラウド推論コストは、収益サイドの数字とセットで設計しないと意味がないと感じます。
どこから手を付けるか ─ ハイブリッド化の最初の 1 歩
ハイブリッド推論は、いきなり全面導入するとデバッグが難しくなります。私が個人開発で実際にやった順番を共有します。
最初の週は、まず「Gemini Vision に送る前のメトリクス(鮮明度・顕著性・サイズ)」をローカル DB に貯めるだけにしました。判定は Gemini Vision にすべて任せたまま、「もしこのスコアでゲートを引いていたら、どれだけ削れたか」を見積もるダッシュボードを Swift Charts で作りました。これにより、削れる量とリスクを定量化してから本実装に入れます。
2 週目に、最も保守的なしきい値(実データの 5 パーセンタイル相当)でゲートを on にし、誤却下率を測定。
3 週目に、ユーザー問い合わせベースの誤却下率が許容範囲(私の場合 0.01%)であることを確認してから、しきい値を段階的に厳しくしていきました。
「最初から理想形を作らず、削れる余地を見える化してから設計する」 ─ 個人開発でクラウド推論コストの最適化に取り組むなら、私はこの順番を強く推奨します。
設計を振り返って
ハイブリッド推論は「Gemini を使わない」設計ではなく、「Gemini を活かすために、Gemini に送る画像を整える」設計です。両家の祖父が宮大工だった影響で、何かを長く使い続けるための作法という言葉が私の中にずっと残っているのですが、AI を本番で長く使うためにも、似た作法が必要になってきていると感じます。
クラウド推論を全件で使ったほうが、設計はずっとシンプルです。私自身、コスト圧力がなければ全面クラウドのままだったと思います。けれど、個人開発の事業として続けるなら、「いつ削るか・いつ送るか」を自分の手で決められる構造にしておくと、AI の進化やコスト体系の変化に揺さぶられにくくなります。
今は次の課題として、Gemini Vision の応答をオンデバイス側にさらに蒸留して、頻出パターンの分類はオフラインでも可能にする道を試しています。進捗はまた別の記事に書きたいと思っています。お読みいただきありがとうございました。