個人開発を2014年から続けていると、ある時期に「手作業の限界」が数字ではなく体感として先にやってきます。私が運営している壁紙アプリでも、収録枚数が増えるにつれて、画像の管理そのものが開発時間を削る側に回っていました。
数百枚の新しい壁紙を追加するたびに、カテゴリ(「自然」「都市」「抽象」「動物」など)を手動でタグ付けしていたのですが、これが地味に時間を奪っていました。1枚あたり10〜15秒としても、500枚で約2時間。月に複数回アップデートするアプリで、この作業が続くことへの疲弊感は本物でした。
Claude Vision API が使えるようになった頃、「これで壁紙の自動分類ができるかもしれない」と試してみたのが今回の話です。結論を先に言うと、精度は想定より高く、コストは想定より低く、落とし穴は想定通りあった、という感じです。
なぜ既存の画像分類ではダメだったのか
試みは Claude Vision が最初ではありませんでした。Google Cloud Vision API も試しましたし、Apple の Vision フレームワークも組み込んだことがあります。
問題は「精度の方向性」でした。既存の画像認識モデルは、「これは犬の写真」「これは建物」のような客観的なラベリングは得意です。でも、壁紙アプリが必要とするのは、ユーザーが探す際の気持ちに合ったカテゴリです。
たとえば夕焼けの都市風景の写真。Google Vision は「sky」「sunset」「building」と返してきます。でもアプリの中では「都市夜景・夕暮れ」というカテゴリに入れたいし、ユーザーも「夕暮れっぽい壁紙」を探してそこに辿り着く。この「人間の感覚に合ったカテゴリ判断」が、従来の分類モデルには難しかったのです。
Claude は言語モデルとしての文脈理解があるので、「このアプリのカテゴリ体系に合わせて分類してください」という指示が通るのではないか、と仮説を立てました。
実装:バッチ処理スクリプトの基本構成
実際に書いたスクリプトの骨格です。本番環境で動かしているものを整理・簡略化したものです。
import anthropic
import base64
import json
from pathlib import Path
client = anthropic.Anthropic(api_key="YOUR_API_KEY")
# アプリで使用するカテゴリ定義(実際のカテゴリ体系に合わせる)
CATEGORIES = [
"自然・風景", "都市・建築", "抽象・パターン",
"動物・生き物", "花・植物", "海・水辺",
"夜景・夕暮れ", "宇宙・天体", "季節・行事",
"シンプル・ミニマル"
]
def classify_wallpaper(image_path: str) -> dict:
"""1枚の壁紙画像をカテゴリ分類する"""
with open(image_path, "rb") as f:
image_data = base64.standard_b64encode(f.read()).decode("utf-8")
# 拡張子から media_type を判定
ext = Path(image_path).suffix.lower()
media_type_map = {".jpg": "image/jpeg", ".jpeg": "image/jpeg",
".png": "image/png", ".webp": "image/webp"}
media_type = media_type_map.get(ext, "image/jpeg")
response = client.messages.create(
model="claude-haiku-4-5-20251001", # コスト重視でHaikuを使用
max_tokens=256,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": media_type,
"data": image_data
}
},
{
"type": "text",
"text": f"""この画像は壁紙アプリ用の画像です。
以下のカテゴリから最も適切なものを1つ選び、JSON形式で回答してください。
カテゴリ: {json.dumps(CATEGORIES, ensure_ascii=False)}
回答形式:
{{"category": "カテゴリ名", "confidence": 0.0〜1.0, "reason": "選んだ理由を1文で"}}
他の形式での回答は不要です。"""
}
]
}]
)
result_text = response.content[0].text.strip()
return json.loads(result_text)
IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp"}
def collect_images(image_dir: str) -> list:
"""対象ディレクトリから画像ファイルだけを拾う"""
return sorted(
p for p in Path(image_dir).iterdir()
if p.is_file() and p.suffix.lower() in IMAGE_EXTS
)
def batch_classify(image_dir: str, output_file: str):
"""ディレクトリ内の全画像を分類してJSONで保存する"""
image_files = collect_images(image_dir)
results = {}
for i, img_path in enumerate(image_files):
print(f"[{i+1}/{len(image_files)}] {img_path.name}")
try:
result = classify_wallpaper(str(img_path))
results[img_path.name] = result
except Exception as e:
print(f" ❌ エラー: {e}")
results[img_path.name] = {"category": "未分類", "error": str(e)}
with open(output_file, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"\n✅ 完了: {len(results)}件を {output_file} に保存")最初に踏んだのは API ではなくファイル検索の穴
上のコードで collect_images() をわざわざ切り出しているのには理由があります。最初に書いた版では、対象ファイルの収集をこう書いていました。
image_files = list(Path(image_dir).glob("*.{jpg,jpeg,png,webp}"))シェルの感覚で書くと、これで4種類の拡張子を拾ってくれそうに見えます。実際には1枚も返りません。pathlib の glob() はブレース展開に対応しておらず、{jpg,jpeg,png,webp} を文字そのものとして解釈するためです。
手元で確かめると差は明確でした。
from pathlib import Path
# a.jpg / b.png / c.webp / d.jpeg を置いたディレクトリで実行
p = Path(".")
print(p.glob("*.{jpg,jpeg,png,webp}")) # → 空
print([x.name for x in p.iterdir()
if x.suffix.lower() in {".jpg", ".jpeg", ".png", ".webp"}])
# → ['a.jpg', 'b.png', 'c.webp', 'd.jpeg']厄介だったのは、例外が飛ばないことでした。スクリプトは何事もなく走り、完了: 0件を保存 とだけ出して終わります。API キーもモデル名も疑って30分ほど溶かしたあとで、ようやくループの手前で止まっていたと気づきました。
以来、バッチ処理の入口には対象件数の表示を必ず挟むようにしています。
image_files = collect_images(image_dir)
if not image_files:
raise SystemExit(f"対象画像が見つかりません: {image_dir}")
print(f"対象: {len(image_files)}件")たった3行ですが、「入力が空」と「処理が失敗」を切り分けてくれます。API を絡めた自動化では、この2つを取り違えたときの調査コストがいちばん高くつきます。
使うモデルの選択:SonnetかHaikuか
ここで実際に悩みました。精度を取るなら Sonnet、コストを取るなら Haiku、という判断です。
壁紙500枚をバッチ処理する場合、コスト試算は次の通りでした(2026年5月時点の参考値)。
Claude Haiku 4.5 を使った場合:
- 入力: 画像1枚あたり約1,000〜1,500トークン相当 + テキスト約200トークン
- 出力: 約100トークン
- 500枚処理での概算コスト: $0.5〜$1.0
Claude Sonnet 4.5 を使った場合:
- 同条件で概算コスト: $3〜$6
月に500枚追加する運用で毎月の差は5ドル前後。絶対額で見れば大きくありませんが、個人開発のコスト感覚としては「精度の差が許容できるなら Haiku で十分」という判断になりました。
実際に両モデルで100枚ずつ試したところ、カテゴリ一致率の差は約5%(Sonnet: 94%、Haiku: 89%)でした。残り11%の Haiku の誤分類も、「致命的に間違い」というより「この壁紙なら都市でも自然でも正直どちらでもいい」という境界ケースがほとんどで、アプリ運営上は問題ない範囲でした。
Claude Vision の認識精度を改善する前処理の手法も参考になります。画像の解像度やトリミングで精度が変わる点は、壁紙のような高解像度アセットでも適用できる知見です。
実際の分類精度:75枚の手動検証結果
500枚を処理した後、ランダムに75枚を抜き出して手動で正解カテゴリを付け直し、Claude の分類結果と比較しました。
結果:
- 完全一致: 66枚(88%)
- 許容範囲の違い(夜景と都市など): 7枚(9.3%)
- 明らかな誤分類: 2枚(2.7%)
2枚の誤分類の内訳:
- 深い森の中の小道 → 「抽象・パターン」と分類(実際は「自然・風景」)
- 宇宙背景に人物シルエット → 「宇宙・天体」と分類(実際は「抽象・パターン」寄り)
誤分類ケースを見ると、どちらも「確かにそう見える」内容で、人間でも迷う画像でした。2.7% の誤分類率は、手作業の疲れから来る凡ミスと比べても遜色ない水準だと判断しています。
実運用で踏んだ3つの落とし穴
1. JSON パースエラーが思ったより多い
Claude は基本的に JSON を正確に返してくれますが、稀に余計な説明文が混入することがあります。
# 対策:JSON部分だけを抽出する
import re
def safe_parse_json(text: str) -> dict:
"""レスポンスからJSON部分を安全に抽出する"""
# コードブロック内のJSONを試みる
code_match = re.search(r'```(?:json)?\n(.*?)\n```', text, re.DOTALL)
if code_match:
return json.loads(code_match.group(1))
# 通常のJSONオブジェクトを試みる
json_match = re.search(r'\{.*\}', text, re.DOTALL)
if json_match:
return json.loads(json_match.group())
raise ValueError(f"JSONが見つかりません: {text[:100]}")出力形式を確実に固定したい場合は、プレフィルによる JSON 応答の多層防御で扱っている手法が効きます。ただし壁紙分類のような軽量タスクでは、Haiku とプロンプト設計にフォールバックパーサを足すだけで足りました。
2. レート制限との衝突
500枚を一気に処理しようとすると、Tier によってはレート制限に引っかかります。バッチ処理には必ず時間あたりのリクエスト数を制御する仕組みが必要です。
import time
def batch_classify_with_rate_limit(image_files: list, max_per_minute: int = 40):
"""レート制限を考慮したバッチ処理"""
results = {}
interval = 60.0 / max_per_minute # リクエスト間隔(秒)
for i, img_path in enumerate(image_files):
start = time.time()
result = classify_wallpaper(str(img_path))
results[str(img_path)] = result
elapsed = time.time() - start
sleep_time = max(0, interval - elapsed)
if sleep_time > 0:
time.sleep(sleep_time)
if (i + 1) % 50 == 0:
print(f" → {i+1}件処理済み")
return resultsレート制限の詳細な扱い方については、API レート制限とベストプラクティスで整理されています。
3. 画像サイズが大きいと処理が遅い
壁紙画像は解像度が高い(3,000×5,000px 以上)ものが多く、base64 エンコードするとデータ量が数 MB になります。API への送信に時間がかかり、バッチ処理全体が遅くなりました。
対策として、分類用には長辺 512px にリサイズしたサムネイルを使うようにしました。カテゴリ判断に高解像度は不要で、処理時間は約1/3 になりました。
from PIL import Image
import io
def resize_for_classification(image_path: str, max_size: int = 512) -> bytes:
"""分類用に画像をリサイズする(アスペクト比保持)"""
with Image.open(image_path) as img:
img.thumbnail((max_size, max_size), Image.LANCZOS)
buffer = io.BytesIO()
img.save(buffer, format="JPEG", quality=85)
return buffer.getvalue()途中で落ちる前提で組む
レート制限を避けながら500枚を流すと、実行時間は十数分に及びます。その間にネットワークが切れることも、うっかりターミナルを閉じることもあります。最初の頃はそのたびに先頭から回し直していて、同じ画像に何度も課金していました。
対策は素朴で、結果ファイルを毎回読み込んでから始めるだけです。
def load_done(output_file: str) -> dict:
"""既存の結果を読み込む(無ければ空で始める)"""
path = Path(output_file)
if not path.exists():
return {}
with path.open(encoding="utf-8") as f:
done = json.load(f)
# エラーで終わった分はやり直す
return {k: v for k, v in done.items() if "error" not in v}
def batch_classify_resumable(image_dir: str, output_file: str, save_every: int = 20):
"""途中から再開できるバッチ処理"""
results = load_done(output_file)
targets = [p for p in collect_images(image_dir) if p.name not in results]
print(f"対象: {len(targets)}件(処理済みスキップ: {len(results)}件)")
for i, img_path in enumerate(targets, 1):
try:
results[img_path.name] = classify_wallpaper(str(img_path))
except Exception as e:
print(f" 失敗: {img_path.name} — {e}")
results[img_path.name] = {"category": "未分類", "error": str(e)}
# 定期的に書き出す(最後にまとめて書くと落ちたとき全損する)
if i % save_every == 0 or i == len(targets):
with open(output_file, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f" → {i}/{len(targets)} 保存")要点は2つあります。ひとつは、error を含むエントリは処理済みとみなさず再試行の対象に戻すこと。一時的なレート制限で失敗した画像を、永久に「未分類」で固定してしまわないためです。
もうひとつは、最後にまとめて書き出さないこと。20件ごとの書き込みなら、最悪でも失うのは19件分です。ファイル I/O の回数を惜しんで全損する設計より、はるかに安く済みます。
なお、リサイズ処理を挟む場合は classify_wallpaper() にパスではなくバイト列を渡す形へ変えておくと、原寸の読み込みが一度で済みます。前掲の resize_for_classification() の戻り値をそのまま base64 エンコードに回すだけです。
実体験から得たコスト感覚
2014年からアプリ開発を続けてきた感覚として、自動化ツールの価値は「絶対コスト」より「機会コスト」で測る方が正確だと思っています。
月に2〜3ドルかかる API コストよりも、月に4〜6時間の手作業コストの方が遥かに重い。個人開発者の時間は何よりも有限なリソースです。複数のアプリを1人で保守しながら新機能を作り、さらに書きものにも時間を割こうとすると、こうした「小さな自動化の積み重ね」がそのまま可処分時間になります。
見落としていた副次効果もありました。分類の一貫性です。手作業で何日かに分けてタグ付けしていた頃は、同じような画像がその日の気分で別のカテゴリに入っていました。API は毎回同じ基準で返すため、アプリ内のカテゴリ分布そのものが安定します。
Claude Vision API による壁紙分類の自動化は、月額数ドルの投資で毎月数時間を取り戻してくれています。計算すると、自分の時給換算での費用対効果は数十倍以上。個人開発者にとってこれ以上の投資はなかなかありません。
次のステップ:人間レビューと組み合わせた仕組みへ
完全自動化より、「AI で8割分類 → 人間が2割だけ確認」のハイブリッド方式が現実的です。
confidence スコアが 0.8 以下のものだけ人間がチェックする運用にしたところ、確認件数は全体の約15%に絞られました。残り85%はそのまま使える品質になっています。
ただし、この confidence はモデルが自己申告した値であって、較正された確率ではありません。0.9 と返ってきた誤分類も実際にありました。私は閾値を「正しさの保証」ではなく「レビューの並び順」として扱っています。時間が20分しか取れない日は、スコアの低い順に20分ぶんだけ見る。しきい値を絶対視するより、この運用の方が現実に合っていました。
もし壁紙アプリ以外の用途でも Claude Vision を使った分類を試みるなら、まずカテゴリ定義を丁寧に言語化することを強くお勧めします。「自然・風景」より「森・山・空など自然物が主役の構図」と書く方が、Claude が判断しやすく精度も上がりました。
この実装パターンは、EC サイトの商品画像分類や、写真管理ツールのタグ付けにも応用できると思います。試せる環境があれば、まず 50 枚程度でプロトタイプを動かしてみてください。費用は数十円以内で収まります。
私自身、この仕組みを組んでから「壁紙を追加する」という作業が、億劫なタスクから15分で終わる手順に変わりました。個人開発では、こうした地味な工程がそのまま継続できるかどうかを左右します。同じところで手が止まっている方の参考になれば幸いです。お読みいただきありがとうございました。