壁紙アプリを運営していると、画像の仕分け作業に想像以上の時間を取られます。2014年から個人開発を続けてきた中で、Beautiful HD Wallpapers(iOS/Android、累計5,000万DL超)のカテゴリ管理は長年の悩みの種でした。
新しい壁紙を追加するたびに「自然・建築・抽象・動物…」という30種以上のカテゴリへの手動分類が必要です。1枚あたり3〜5秒かかっていても、数百枚が溜まればまとまった作業時間になります。アプリの改善に使いたい時間が、地味な仕分け作業に消えていく感覚は、同じように壁紙やコンテンツ系のアプリを運営している方には伝わるものがあると思います。
そこで Gemini Vision API を使って自動分類を試みたのですが、最初の実装で思わぬ落とし穴にはまりました。その経緯と、精度を実用レベルに引き上げるまでの改善プロセスをお伝えします。
最初の実装と、あっさり外れた期待
最初に書いたコードはシンプルなものでした。カテゴリ名を並べて「どれか選んでください」と投げるだけです。
import google.generativeai as genai
import base64
from pathlib import Path
genai.configure(api_key="YOUR_GEMINI_API_KEY")
model = genai.GenerativeModel("gemini-2.0-flash")
def classify_wallpaper(image_path: str) -> str:
"""壁紙画像をカテゴリに分類する(初版・改善前)"""
image_data = Path(image_path).read_bytes()
image_b64 = base64.b64encode(image_data).decode()
response = model.generate_content([
{
"inline_data": {
"mime_type": "image/jpeg",
"data": image_b64
}
},
"この壁紙画像のカテゴリを1つ選んでください: 自然, 建築, 抽象, 動物, 都市, 宇宙, 食べ物, スポーツ, その他"
])
return response.text.strip()
result = classify_wallpaper("sample_sunset.jpg")
print(result) # → "自然"動作確認では「自然」と返ってくることが多く、一見うまく動いているように見えました。しかし実際に500枚のバッチ処理を走らせてみると、精度は約67%でした。3枚に1枚は間違っている計算です。
精度67%の原因分析 — 3つの根本問題
何が問題だったか、原因を分析すると3点に絞られました。
問題1: 出力フォーマットがばらつく
「自然」と返ってほしい場面で「自然風景」「風景・自然」「自然(森林)」のように微妙に違う文字列が返ってくることがありました。後続の処理でカテゴリ名を比較しているため、これがそのまま分類ミスになっていました。単純なテキスト応答に任せると、モデルは自由に言い回しを変えてきます。フォーマットを強制する設計が最初から必要でした。
問題2: 境界ケースへの対処がない
夕焼けの都市景観は「自然」か「都市」か、というような境界事例に対して、モデルは毎回異なる答えを返します。同じ画像でも呼び出すたびに結果が変わることがありました。初版の実装には「迷ったらどちらを優先するか」というルールがありませんでした。画像の面積比率で判定するなど、明示的な優先順位をプロンプトで与えてやる必要があります。
問題3: カテゴリ定義が曖昧で「その他」が逃げ道になっている
「その他」の存在がモデルを迷わせていました。カテゴリの定義を曖昧なままにしておくと、モデルは「その他」に大量の画像を流し込んでしまいます。各カテゴリが何を指すかを明示し、「その他」は最終手段として位置づけることが大切です。また、カテゴリ同士の違いが曖昧な箇所(「自然」と「ミニマル」の境界など)も、定義文で補足する必要があることがわかりました。
改善後の実装 — JSON出力 + 構造化プロンプトで精度87%へ
試行錯誤を経て、以下の2点の改善で精度が87%まで上がりました。
import google.generativeai as genai
import json
import base64
from pathlib import Path
genai.configure(api_key="YOUR_GEMINI_API_KEY")
model = genai.GenerativeModel("gemini-2.0-flash")
CATEGORIES = [
"nature", # 自然(森・山・海・空・植物)
"architecture", # 建築・構造物
"abstract", # 抽象・テクスチャ・パターン
"animals", # 動物・生き物
"cityscape", # 都市・夜景・街
"space", # 宇宙・星・銀河
"minimal", # ミニマル・シンプル
"other", # 上記に当てはまらない場合のみ
]
SYSTEM_PROMPT = """
あなたは壁紙画像の分類AIです。以下のルールに従って分類してください。
ルール:
1. 必ず指定されたカテゴリリストから1つだけ選んでください
2. JSON形式で { "category": "カテゴリ名", "confidence": 0〜100, "reason": "理由を20文字以内" } を返してください
3. 境界ケース(例: 夕焼けの都市)は、画像の面積比率が多い要素を優先してください
4. "other" は上記のどれにも明確に当てはまらない場合のみ使用してください
カテゴリ定義:
- nature: 自然要素(森・山・海・空・植物・川)が主役の画像
- architecture: 建物・橋・構造物が主役。自然背景との組み合わせは面積で判定
- abstract: 幾何学模様・テクスチャ・グラフィックパターン・CGアート
- animals: 動物・昆虫・魚類など生き物が主役
- cityscape: 都市・ビル群・夜景・街並み
- space: 宇宙・星・銀河・惑星
- minimal: 余白が多いシンプルな構図
- other: 上記に当てはまらない場合のみ
"""
def classify_wallpaper_v2(image_path: str) -> dict:
"""壁紙画像をカテゴリに分類する(改善版)"""
image_data = Path(image_path).read_bytes()
image_b64 = base64.b64encode(image_data).decode()
response = model.generate_content(
[
{
"inline_data": {
"mime_type": "image/jpeg",
"data": image_b64
}
},
f"画像を分類してください。カテゴリ: {', '.join(CATEGORIES)}\n{SYSTEM_PROMPT}"
],
generation_config=genai.types.GenerationConfig(
response_mime_type="application/json", # JSON出力を強制
temperature=0.1, # ランダム性を抑えて安定させる
)
)
result = json.loads(response.text)
# カテゴリ名のバリデーション
if result.get("category") not in CATEGORIES:
result["category"] = "other"
result["confidence"] = 0
return result
result = classify_wallpaper_v2("sunset_city.jpg")
print(result)
# → {"category": "cityscape", "confidence": 72, "reason": "ビル群と夕焼けで都市面積が大"}response_mime_type="application/json" でJSON出力を強制したことで、カテゴリ名の揺れが完全になくなりました。temperature=0.1 に設定することで、同じ画像に対して毎回同じ分類結果が返るようになりました。分類タスクにおいてランダム性は不要で、再現性の方がはるかに重要です。
バッチ処理での実装 — レート制限と途中再開の設計
実際の壁紙アプリでは数百〜数千枚を一括処理することになります。Gemini Flash の無料枠では1分あたり15リクエストの制限があります。また、処理中にエラーが起きたとき最初からやり直しにならないよう、途中再開できる設計にしておくことが大切です。
import time
import json
from pathlib import Path
def batch_classify_wallpapers(
image_dir: str,
output_json: str,
requests_per_minute: int = 12, # 制限15に対して安全マージンを確保
) -> dict:
image_dir = Path(image_dir)
output_path = Path(output_json)
# 既存の結果を読み込んで途中再開
results = {}
if output_path.exists():
with open(output_path) as f:
results = json.load(f)
images = list(image_dir.glob("*.jpg")) + list(image_dir.glob("*.png"))
interval = 60.0 / requests_per_minute
for i, image_path in enumerate(images):
filename = image_path.name
if filename in results:
continue # スキップ: 既に分類済み
try:
result = classify_wallpaper_v2(str(image_path))
results[filename] = {
"category": result["category"],
"confidence": result.get("confidence", 0),
"reason": result.get("reason", ""),
}
if (i + 1) % 10 == 0:
with open(output_path, "w") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"Progress: {i+1}/{len(images)}")
except Exception as e:
print(f"Error: {filename} → {e}")
results[filename] = {"category": "error", "confidence": 0, "reason": str(e)[:50]}
time.sleep(interval)
with open(output_path, "w") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
return results10件ごとに中間保存する設計は、予期しないプロセス終了への保険として機能します。実際のバッチ処理中にレート制限エラーで止まったとき、最初からやり直しではなく続きから再開できたことで、かなりの時間を節約できました。
信頼度スコアを活用した人間レビューとの組み合わせ
精度87%というのは、裏を返せば「全体の13%は間違っている」ということです。これを実運用に使うには、信頼度スコアで人間レビューが必要な画像を絞り込む設計が現実的です。
実際に500枚で検証した結果、confidence が70未満の画像の約45%が誤分類でした。一方、confidence が90以上の画像の正解率は97%でした。この差を使って、人間によるレビュー対象を絞り込みます。
def needs_human_review(result: dict) -> bool:
"""人間によるレビューが必要かどうかを判定"""
if result["confidence"] < 70:
return True
if result["category"] == "other":
return True
return Falseこの閾値を設けることで、全体の約15%の画像だけを人間が確認すればよくなりました。500枚なら75枚です。以前は全部手動でやっていたことを考えると、作業量は6分の1以下になった計算です。完全自動化は現実的ではありませんでしたが、人間が判断すべき部分を明確に切り出せたことで、運用の見通しが立ちました。
APIコストと個人開発での現実的な使い方
個人開発者として気になるのがコストです。Gemini 2.0 Flash の場合、画像1枚あたりのAPIコストは記事執筆時点で非常に低く、月に1,000枚の新規画像を処理するとしても無料枠で十分に収まります。有料プランへの移行コストと照らし合わせても、個人開発の規模では無料枠(1分15リクエスト)で十分です。レート制限で処理速度は遅くなりますが、バッチ処理を夜間に走らせれば翌朝には完了しています。
よくある誤分類パターンと対策
2ヶ月間の運用で観察した主な誤分類のパターンは以下の3つでした。
1. 夕焼け・朝焼けを含む画像
夕焼けや朝焼けは、都市・自然・宇宙のどのカテゴリにも隣接しているため、モデルが最も迷いやすい対象でした。プロンプトに「空の色よりも被写体の面積で判定する」という補足を加えることで、この種の誤分類を大幅に減らすことができました。
2. CGや3Dレンダリングの画像
写真とCGを区別することはモデルにとって難しく、3Dレンダリングされた森や都市の画像が「nature」や「cityscape」に分類されることがありました。これは abstract カテゴリの定義に「CGアート・3Dレンダリング」を明記することで改善しました。
3. 光・ボケ・フレアのみの画像
光のボケや水面の反射だけを撮影した画像は、abstract と minimal の間で迷いが生じやすいです。minimal は「余白が主役」、abstract は「模様やテクスチャが主役」と定義を分けることで、分類の一貫性が上がりました。
実装してみて感じたこと
Beautiful HD Wallpapers の画像管理に Gemini Vision を組み込んでから2ヶ月ほど経ちます。両家の祖父が宮大工だったこともあり、「手を動かすことが一つの信心」という感覚が根底にあります。最初は「機械に仕分けを任せていいのか」という違和感がありましたが、実際に使ってみると、AIが分類に迷う画像は人間が見ても迷う画像であることが多く、「ここは人間が判断する」という線引きが自然にできてきました。
完全自動化を目標にするのではなく、AIが得意な「明確な事例の高速処理」と、人間が得意な「境界事例の文脈判断」を組み合わせることが、個人開発者の限られた時間を有効に使う現実的な方法だと感じています。
Gemini Vision の活用パターンをさらに詳しく知りたい方は、Google AI Studio の公式ドキュメント が実践的な参考になります。
まず手持ちの画像10枚で精度を確認し、プロンプトと temperature を調整してから本番バッチに進むことをお勧めします。