◉CLAUDE LABEN
●2.1.301 — vim の % モーション、VS Code パネルでの LaTeX 数式表示、使用量上限待ちのリトライ監視の上限が入りました(10/15)●2.1.299 — 200K トークン超で約54分放置した会話はアイドル圧縮。MCP の許可ルールは名指ししたサーバーだけに一致します●SONNET 4.5 — Claude API での提供終了は 11/30、残り45日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました●2.1.301 — vim の % モーション、VS Code パネルでの LaTeX 数式表示、使用量上限待ちのリトライ監視の上限が入りました(10/15)●2.1.299 — 200K トークン超で約54分放置した会話はアイドル圧縮。MCP の許可ルールは名指ししたサーバーだけに一致します●SONNET 4.5 — Claude API での提供終了は 11/30、残り45日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました
記事一覧/API & SDK
⬡ API & SDK/2026-05-15中級

壁紙アプリの一括カテゴリ分類を Claude Vision API で自動化した話 — 実運用で見えた精度と限界

個人開発の壁紙アプリで画像のカテゴリ分類をClaude Vision APIに任せた実体験の記録です。SonnetとHaikuのモデル選択、75枚の手動検証で確かめた分類精度、実運用で踏んだ落とし穴、途中で落ちても再開できるバッチ設計、そして実際にかかったコストの感覚まで正直に書きました。

Claude API128Vision4画像認識個人開発130自動化87

個人開発を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枚の誤分類の内訳:

  1. 深い森の中の小道 → 「抽象・パターン」と分類(実際は「自然・風景」)
  2. 宇宙背景に人物シルエット → 「宇宙・天体」と分類(実際は「抽象・パターン」寄り)

誤分類ケースを見ると、どちらも「確かにそう見える」内容で、人間でも迷う画像でした。2.7% の誤分類率は、手作業の疲れから来る凡ミスと比べても遜色ない水準だと判断しています。

答え合わせの結果を、次の改善に回す

75枚の手動検証は、数字を出して終わりにしていたあいだは、ほとんど役に立ちませんでした。「88%でした」と満足して、プロンプトには一文字も触れなかったのです。

気づいたのは、間違いの一覧を「どのカテゴリがどのカテゴリに化けたか」の組で並べ直したときでした。人間側の正解と Claude の回答を突き合わせるだけの、ごく短いスクリプトです。

from collections import Counter
import json
 
def tally_mismatches(manual_file: str, auto_file: str) -> Counter:
    """手動ラベルと自動分類のズレを (正解, 回答) の組で数える"""
    with open(manual_file, encoding="utf-8") as f:
        manual = json.load(f)   # {"001.jpg": "自然・風景", ...}
    with open(auto_file, encoding="utf-8") as f:
        auto = json.load(f)     # batch_classify の出力
 
    pairs = Counter()
    for name, truth in manual.items():
        guess = auto.get(name, {}).get("category", "未分類")
        if guess != truth:
            pairs[(truth, guess)] += 1
    return pairs
 
for (truth, guess), n in tally_mismatches("manual.json", "results.json").most_common():
    print(f"{n:>3}  {truth} → {guess}")

私の場合、並び順の上位に来たのは「夜景・夕暮れ」と「都市・建築」の行き来でした。カテゴリ定義の文面で、夕暮れの街並みがどちらにも読めていたのです。つまりモデルの目が悪いのではなく、私の定義が曖昧だったのでした。

そこで境界の線引きを一文だけ足しました。「建物が主役なら都市・建築、空の色が主役なら夜景・夕暮れ」。それ以降、同じ組み合わせのズレは目に見えて減りました。

もうひとつ、同じ画像を2回流して結果が割れないかを見ておくと安心です。temperature を 0 にして固定しても、境界の画像は回答がぶれることがあります。2回の結果が食い違った画像は、モデルも迷っている画像です。そのまま人間の確認リストの先頭に回しています。

間違いは件数ではなく「組」で数える——この一行の原則だけで、プロンプトの直し方がはっきり見えるようになりました。

実運用で踏んだ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分で終わる手順に変わりました。個人開発では、こうした地味な工程がそのまま継続できるかどうかを左右します。同じところで手が止まっている方の参考になれば幸いです。お読みいただきありがとうございました。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⬡ API & SDK2026-09-17
画像は20枚ずつ送っています — 21枚目から全画像に別の上限がかかると知った日
素材フォルダの62枚を一度に送ったら invalid_request_error で落ちました。容量ではなく枚数が原因です。視覚トークンを28pxのパッチで数え直し、枚数・寸法・ペイロードの3つの上限を同時に見てバッチを組むまでの記録です。
⬡ API & SDK2026-09-06
訳文は自然なのに、実行時に落ちる翻訳がありました
多言語アプリの文言を Claude API で訳したとき、意味は正しいのに書式指定子だけが壊れることがあります。重大度で分けた受け入れ検査と、壊れた行だけを訳し直す修復ループを実装と実測でまとめました。
⬡ API & SDK2026-05-14
Claude API でアプリ内 AI チャットを実装して踏んだ6つの罠 — 本番投入までの設計記録
Claude APIをアプリ内チャットに組み込んで本番で直面した6つの設計ミスを、個人開発での実運用経験から解説。コンテキスト設計、ストリーミング、セッション管理、コスト監視の実装パターンを動くコード付きで紹介します。