Beautiful HD Wallpapersの Android 版(v2.0.0)を Google Play に出す準備をしていた頃、壁紙のカテゴリ分類に頭を抱えていました。アプリには数千枚の画像があり、「自然」「都市」「抽象」「動物」「宇宙」といった30以上のカテゴリに正確に分類するのが、長年の課題でした。手作業で行うと数日かかる作業を、Gemini Vision API でどこまで自動化できるか試した記録です。
結論から言うと、精度は想像以上でしたが、画像の曖昧さに対するモデルの振る舞いが公式ドキュメントだけでは読み切れず、実装面でいくつかの工夫が必要でした。
個人開発を長く続けるなかで、画像の整理・分類作業はいつも頭の痛い問題でした。特に Beautiful HD Wallpapers は画像点数が多く、新しい壁紙を追加するたびにカテゴリ判断へ時間を取られていました。「いつかAIで自動化できないか」と思いながら年月が過ぎ、ようやく実用に足る解が見つかったという感覚があります。
なぜ Gemini Vision を選んだか
壁紙の自動分類には、いくつかの選択肢がありました。
ローカルの画像分類モデル(MobileNet, EfficientNet)は高速で安価ですが、カテゴリ定義が固定されており、「幻想的な森」「水彩画風の夜景」のような曖昧なカテゴリへの対応が難しいです。これらのモデルはImageNetのクラス分類に最適化されており、独自カテゴリへの転移学習には相応のデータとトレーニング工数が必要になります。
GPT-4o Vision は精度が高い一方で、Gemini 2.0 Flash と比較して1リクエストあたり約2〜3倍のコストがかかります。個人開発で数千枚の画像を一括処理することを考えると、この差は無視できません。
最終的に Gemini 2.0 Flash(マルチモーダル)を選んだ理由は、コストと精度のバランスでした。2026年5月時点で 1,000トークンあたり約$0.0001 程度。GPT-4o Vision と比べると大幅に安価です。加えて、Function Calling による構造化出力との親和性が高く、JSON形式での回答をプロンプト側でコントロールしやすい点も魅力でした。
基本的な実装
まず、Gemini APIで壁紙を分類する最小構成のコードです。
import google.generativeai as genai
from PIL import Image
import json
genai.configure(api_key="YOUR_GEMINI_API_KEY")
# カテゴリ定義(アプリのカテゴリ一覧)
CATEGORIES = [
"nature", "city", "abstract", "animals", "space",
"ocean", "mountain", "forest", "flower", "architecture",
"art", "minimal", "dark", "colorful", "japan",
]
def classify_wallpaper(image_path: str) -> dict:
"""
壁紙画像をGemini Visionで分類する
戻り値: {"category": "nature", "confidence": "high", "reason": "..."}
"""
model = genai.GenerativeModel("gemini-2.0-flash")
image = Image.open(image_path)
prompt = f"""
あなたは壁紙アプリのカテゴリ分類システムです。
この画像を以下のカテゴリから1つだけ選んでください:
{', '.join(CATEGORIES)}
以下のJSON形式で回答してください:
{{
"category": "選んだカテゴリ(英語)",
"confidence": "high / medium / low",
"reason": "分類の根拠(日本語で1文)"
}}
必ずJSON形式のみで回答し、他の説明は不要です。
"""
response = model.generate_content([prompt, image])
# JSON抽出(モデルが```jsonブロックで囲んで返すことがある)
text = response.text.strip()
if "```json" in text:
text = text.split("```json")[1].split("```")[0].strip()
elif "```" in text:
text = text.split("```")[1].split("```")[0].strip()
return json.loads(text)
# 使用例
result = classify_wallpaper("wallpaper_001.jpg")
print(result)
# 出力例: {"category": "nature", "confidence": "high", "reason": "緑豊かな森の朝霧を捉えた写真"}このコードは動きますが、本番投入するとすぐに問題が出てきます。
実際に起きた問題と解決策
JSONパースの失敗
Gemini 2.0 Flash は、指示してもJSON以外のテキストを含めて返すことがあります。特に「confidence: low」の場合に「この画像は分類が難しいです。最も近いカテゴリとしては...」のような前置きを付けることがありました。
最初は単純な json.loads() で対応していましたが、本番では10〜15%のレスポンスでパースに失敗しました。正規表現でJSONブロックを抽出する関数を作ることで安定しました。
import re
def extract_json_safely(text: str) -> dict | None:
"""レスポンステキストからJSONを安全に抽出する"""
# コードブロックを除去
text = re.sub(r'```(?:json)?\n?', '', text).strip()
# JSON部分だけを正規表現で抽出
json_match = re.search(r'\{[^{}]*\}', text, re.DOTALL)
if not json_match:
return None
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
return None公式ドキュメントにはJSONモードの設定として response_mime_type="application/json" を使う方法も記載されています。ただし、この設定を有効にすると reason フィールドに日本語が含まれる場合に文字化けするケースが稀に発生しました。プロンプト側でのJSON指示 + 正規表現抽出の組み合わせの方が、私の環境では安定していました。
曖昧な画像のカテゴリ定義
「抽象的な水彩画の森」「宇宙のような暗い海中」など、複数のカテゴリにまたがる画像で分類が不安定になることがありました。試行錯誤の末、プロンプトに**各カテゴリの「これに含まれない例」**を追加することで精度が大幅に改善しました。
CATEGORY_DESCRIPTIONS = {
"nature": "森・草原・田舎の風景。都市の建物が入っていたら architecture か city を選ぶ",
"ocean": "海・海岸・波。水族館の水中写真も含む。川や湖は nature",
"abstract": "現実の物体が認識できない抽象的なパターン・グラデーション・デジタルアート",
"art": "絵画・イラスト・アニメ風・水彩画。写真でも強く加工されているものも含む",
"japan": "日本の神社仏閣・桜・浮世絵・着物。都市の場合でも日本的な要素が主題であれば japan",
}
def build_prompt_with_descriptions(categories: list, descriptions: dict) -> str:
cat_lines = []
for cat in categories:
desc = descriptions.get(cat, "")
if desc:
cat_lines.append(f"- {cat}: {desc}")
else:
cat_lines.append(f"- {cat}")
return "\n".join(cat_lines)カテゴリの除外例をプロンプトに埋め込んだ後、中信頼度(medium)の精度が78.6%から87.3%に向上しました。
バッチ処理でのレート制限
数千枚をバッチ処理すると、Gemini API のレート制限(無料枠: 15 RPM / 1,000 RPD)に引っかかりました。有料プランでも急激な並列リクエストは制限を受けます。エラーメッセージは 429 Resource Exhausted として返ってきます。
import time
def classify_batch(image_paths: list, rpm_limit: int = 30) -> list:
"""
レート制限を守りながらバッチ処理する
rpm_limit: 1分あたりの最大リクエスト数
"""
results = []
delay = 60 / rpm_limit # リクエスト間隔(秒)
for i, path in enumerate(image_paths):
try:
result = classify_wallpaper(path)
results.append({"path": path, "status": "ok", **result})
except Exception as e:
# 429エラーは少し待ってリトライ
if "429" in str(e):
time.sleep(30)
result = classify_wallpaper(path)
results.append({"path": path, "status": "retry", **result})
else:
results.append({"path": path, "status": "error", "error": str(e)})
if (i + 1) % 10 == 0:
print(f"進行状況: {i + 1}/{len(image_paths)}")
if i < len(image_paths) - 1:
time.sleep(delay)
return results精度の実測値
Beautiful HD Wallpapers の実データ(2,400枚)で検証した結果です。
- 高信頼度(high)の分類精度: 94.2%(2,400枚中 約1,400枚がhigh判定)
- 中信頼度(medium)の分類精度: 78.6%→87.3%(カテゴリ説明追加後)
- 低信頼度(low)の分類精度: 51.3%(約150枚がlow判定)
confidence: low の画像は手動確認キューに入れる運用にすることで、全体の自動化率は約92%を達成しました。Gemini Vision を使う前は、手作業で1日200〜300枚が限界でしたが、バッチ処理で1時間あたり1,000枚以上を処理できるようになりました。
精度の誤分類パターンで特に多かったのは、「japan」カテゴリへの誤判定でした。桜の木が入った「nature」画像を「japan」と判定するケースが頻発したため、japan の定義に「自然風景の中に桜だけがある場合は nature を選ぶ」という一文を追加しました。
Gemini 2.0 Flash と GPT-4o Vision のコスト比較
同じ2,400枚の壁紙を両モデルで分類したときのコストです。
- Gemini 2.0 Flash: 約$0.18(画像+テキスト合計)
- GPT-4o Vision: 約$0.54(同条件)
個人開発の規模では3倍の差は無視できません。精度差は高信頼度の分類で約1〜2%程度でした。コストを許容できるなら GPT-4o の方がやや安定していますが、壁紙分類のような用途では Gemini 2.0 Flash で十分だと感じています。
ただし、美術品・アート作品の分類に使う場合は話が変わります。浮世絵壁紙(Ukiyo-e Wallpapers)の分類では、「これは浮世絵の特定の時代様式か」「作者の特徴があるか」といった細かい判定が必要になることがあり、そこでは GPT-4o の方が明らかに安定していました。用途とコストを天秤にかけて選ぶのが現実的だと思います。
構造化出力で JSON パースを根本から減らす
この記事の実装は、正規表現で JSON を救出する方針でした。ただ、その後 SDK 側の構造化出力が実用域に入り、パース失敗そのものをほぼ起こさずに済むようになりました。新しい google-genai SDK では、response_schema に Pydantic モデルを渡すと、モデルがそのスキーマに沿った JSON だけを返してくれます。
from google import genai
from pydantic import BaseModel
from enum import Enum
class Confidence(str, Enum):
high = "high"
medium = "medium"
low = "low"
class Classification(BaseModel):
category: str
confidence: Confidence
reason: str
client = genai.Client(api_key="YOUR_GEMINI_API_KEY")
def classify_structured(image_path: str) -> Classification:
img = client.files.upload(file=image_path)
resp = client.models.generate_content(
model="gemini-2.5-flash",
contents=["この壁紙を1カテゴリに分類してください。", img],
config={
"response_mime_type": "application/json",
"response_schema": Classification,
},
)
return Classification.model_validate_json(resp.text)スキーマを渡す方式へ切り替えてから、前置きテキストの混入による json.loads() の失敗は、私の手元ではほぼゼロになりました。confidence を Enum で縛っておくと、"とても高い" のような表記揺れも防げます。記事公開当時に困っていた日本語 reason の文字化けも、最近のモデルでは再現しなくなりました。冒頭で紹介した正規表現フォールバックは、response_schema 非対応の古いモデルを併用する場合の保険として残しておけば十分です。
モデル名は手元の利用環境に合わせて選んでください。軽量ティアの Flash 系で一括分類し、判定が割れた画像だけ上位ティアで再分類する二段構えにすると、コストを抑えつつ精度を底上げできます。
自動化と人間の目をどう分けるか
壁紙アプリを長く運営してきて感じるのは、「美しさ」には数値化しきれない何かが残るということです。AI が medium や low と返してくる壁紙のなかにも、人の目が引き寄せられる一枚は確かにあります。逆に high でも、並べてみると単調に見えるものもあります。分類の正しさと、選ばれる魅力は別物だと痛感します。
だからこそ私自身は、完全自動化ではなく「92%自動 + 8%人間」という運用に落ち着いています。明確に判定できる画像は思い切って AI に任せ、confidence: low の手動確認キューに人の目を集中させる。この線引きが、個人で運営を続けるうえでは最も無理がないと感じています。
運用を回すうえで効いたのは、reason フィールドをログに残しておくことでした。誤分類が起きたとき、モデルが「なぜそのカテゴリを選んだか」を後から読み返せるので、プロンプトのどの記述を直せばよいかがすぐ分かります。精度を一度に上げようとせず、reason を手がかりにカテゴリ定義を一文ずつ調整していく。結局これが、いちばん遠回りに見えていちばん早い改善ルートでした。
次のステップ
まず Google AI Studio でAPIキーを取得し、上記の最小構成コードをそのまま試してみてください。gemini-2.0-flash であれば、無料枠の範囲内でも数百枚の分類を試せます。
カテゴリ定義を自分のアプリに合わせて書き換えるだけで、すぐに実用的な分類器として機能します。まずは100枚程度の小さなバッチで試して、精度感を掴んでから本番規模に拡張していくのがおすすめです。