壁紙アプリを運営していると、避けて通れない作業がひとつあります。手元にたまっていく数千枚の画像を、「夜景」「ミニマル」「動物」といったカテゴリにひたすら振り分ける仕事です。
新しい素材が数百枚届くたびに、一枚ずつ眺めてタグを付けていく。最初の頃は手作業でこなしていましたが、枚数が増えるにつれて、これは人間がやるべき作業ではないと感じるようになりました。Gemini API のマルチモーダル機能に画像分類を任せてみようと思ったのは、この実務上の必要からです。
そして先日、しばらく寝かせていたこの分類スクリプトを久しぶりに回そうとして、手が止まりました。依存しているパッケージが旧世代のものになっていたのです。動かないわけではない。けれど、いま同じものを書き直すなら形がまるで違う。
そこで、移行のついでに全体を組み直しました。ここではその後の形で、画像入力・解像度の最適化・構造化された受け取り・再試行・コスト管理までを、一本のつながった実装として示します。
旧 SDK のまま置いていた分類スクリプト
もともとこのスクリプトは @google/generative-ai を使っていました。現在の Gemini API 向けパッケージは @google/genai で、呼び出しの形が変わっています。
大きな違いは三つです。クライアントは new GoogleGenAI({ apiKey }) で作ること。生成は ai.models.generateContent({ model, contents, config }) の一箇所に集約され、モデル名も設定も引数の中に入ること。そして応答テキストは res.text() という関数呼び出しではなく res.text というプロパティになったことです。
この最後の一点で、私は静かに時間を溶かしました。res.text() のまま実行すると「text is not a function」で落ちます。エラーメッセージは正しいのに、頭が旧 SDK の形を覚えているせいで、しばらく別の場所を疑ってしまったのです。
import { GoogleGenAI } from "@google/genai";
import fs from "node:fs";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
async function analyzeImage(imagePath, prompt) {
const base64Image = fs.readFileSync(imagePath).toString("base64");
const res = await ai.models.generateContent({
model: "gemini-flash-latest",
contents: [
{
role: "user",
parts: [
{ inlineData: { mimeType: "image/jpeg", data: base64Image } },
{ text: prompt },
],
},
],
});
// 旧 SDK の res.text() ではなく、プロパティとして参照する
return res.text;
}画像は inlineData に mimeType と Base64 文字列を入れて parts に並べます。テキストと画像を同じ parts 配列に混ぜられるのがマルチモーダルの素直なところで、順番も意味を持ちます。私は画像を先、指示を後に置くようにしています。指示を読んだ直後に判断させたいからです。
モデル名の gemini-flash-latest は、いま Gemini 3.5 Flash を指しています。ここで一度立ち止まる価値があります。エイリアスは新しいモデルが出れば自動的に指し先が変わるため、便利な反面、バッチ処理では厄介です。
同じ画像に対して先月と違うカテゴリが返ってくると、過去に分類済みの数千枚との一貫性が崩れます。私は分類のような「積み上がる処理」では、モデル名を具体的なバージョンで固定し、切り替えは自分のタイミングで行う側に倒しました。逆に単発の要約や下書き生成では latest を追っています。同じ API でも、処理の性質で判断が分かれる部分です。
この「どのモデルに依存しているかを一箇所に閉じ込める」考え方は、モデルの提供終了通知が届くたびに慌てないために でより詳しく整理しています。今回のような移行が三度目になると、最初から層を切っておけばよかったと痛感します。
解像度が精度とコストを同時に決める
ここが、手作業を自動化に切り替えたときに一番効いた部分です。壁紙の元画像は 4K も珍しくありませんが、分類のためにそのまま送るのは無駄が多すぎます。Gemini は画像をタイルに分割してトークン換算するため、解像度が高いほど 1 枚あたりのコストが跳ね上がるのです。
分類タスクなら、長辺 1024px 程度まで縮小しても判定精度はほとんど落ちませんでした。むしろ、余計なディテールが消えることで「夜景か昼景か」のような大局的な判断が安定する場面さえありました。細部を見せないほうが正しく答える、というのは直感に反しますが、考えてみれば人間も同じです。遠くから眺めたほうが全体の印象はつかみやすい。
import sharp from "sharp";
// 分類用に軽量化する。長辺1024px・JPEG品質80で十分に判定できる
async function toClassificationJpeg(inputPath) {
return sharp(inputPath)
.resize(1024, 1024, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
}縮小処理を挟むだけで、同じ枚数を処理したときの請求額が体感で半分以下になりました。広告収益(AdMob)で回しているアプリでは、この差がそのまま運営の余裕につながります。分類精度を 1% 上げることより、コストを半分にしながら精度を保つことのほうが、個人開発では現実的な勝ち筋でした。
なお withoutEnlargement: true は地味ですが入れておいたほうが安全です。素材の中には長辺 800px 程度の小さなものも混ざっていて、これを 1024px へ引き伸ばしても情報は増えず、トークンだけが増えます。
正規表現で JSON を剥がすのをやめる
旧実装で一番みっともなかったのがここです。プロンプトで「JSON のみを返してください」とお願いし、返ってきた文字列から JSON のコードフェンスを正規表現で削ってから JSON.parse していました。
お願いベースなので、たまに裏切られます。前置きの一文が付いた日には、そこでパースが落ちて処理全体が止まる。数千枚を回している最中に一枚のせいで止まるのは、精神衛生上よくありませんでした。
いまは responseSchema で型を宣言し、モデル側に構造を守らせます。
import { GoogleGenAI, Type } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const classificationSchema = {
type: Type.OBJECT,
properties: {
category: {
type: Type.STRING,
// enum で閉じると、存在しないカテゴリを創作されなくなる
enum: ["night", "minimal", "animal", "nature", "abstract"],
description: "画像に最もよく当てはまるカテゴリを1つだけ選ぶ",
},
confidence: {
type: Type.NUMBER,
description: "0.0〜1.0。迷いがある場合は正直に低い値を返す",
},
},
required: ["category", "confidence"],
propertyOrdering: ["category", "confidence"],
};
async function classifyWallpaper(inputPath) {
const buffer = await toClassificationJpeg(inputPath);
const res = await ai.models.generateContent({
model: "gemini-3.5-flash",
contents: [
{
role: "user",
parts: [
{ inlineData: { mimeType: "image/jpeg", data: buffer.toString("base64") } },
{ text: "この壁紙画像を分類してください。" },
],
},
],
config: {
responseMimeType: "application/json",
responseSchema: classificationSchema,
},
});
// フェンス除去も前置きの心配も不要になる
return JSON.parse(res.text);
}効いたのは enum です。カテゴリを文字列で自由に返させていた頃は、「夜景」と「夜」と「ナイトシーン」が混ざって表記ゆれの掃除が必要でした。列挙で閉じてからは、そもそも存在しない値が返ってこないので、下流の正規化コードをまるごと消せました。
description を型の説明ではなく現場のルールを書く場所として使うのも、後から効いてきた工夫です。「0.0〜1.0」とだけ書くより「迷いがある場合は正直に低い値を返す」と添えたほうが、信頼度が素直に散ってくれます。全部 0.95 で返ってくるようでは、次の節のしきい値が意味を失います。
ただし、構造化出力が保証してくれるのは「形」であって「意味」ではありません。この境界の引き方は Gemini の構造化出力を本番で信用するために に切り分けて書いています。
信頼度でしきい値を切る
自動分類を「全自動」にしないことも、運用してわかった大事な判断です。Gemini に必ず信頼度を返させて、低いものだけ人間が見る。この一段を入れるだけで、誤分類がそのまま公開される事故が止まりました。
async function classifyBatch(paths, reviewThreshold = 0.7) {
const auto = [];
const needsReview = [];
for (const path of paths) {
const { category, confidence } = await classifyWallpaper(path);
const record = { path, category, confidence };
if (confidence >= reviewThreshold) {
auto.push(record);
} else {
needsReview.push(record); // 自信のないものだけ手元に集める
}
}
return { auto, needsReview };
}数千枚のうち、人間が確認する必要があったのは結局 1 割ほどでした。残りの 9 割は信頼度 0.7 以上で自動採用し、目視せずに公開フローへ流しています。
しきい値 0.7 に深い根拠はありません。0.5 から始めて、レビュー箱に上がってきたものを眺めながら少しずつ上げていった結果、この辺りで落ち着きました。0.8 まで上げるとレビュー枚数が倍近くになり、その割に拾える誤りが増えなかったのです。数字そのものより、レビュー箱の中身を定期的に見る習慣のほうが大事だと感じています。
「全部を AI に任せる」ではなく「迷ったものだけ人に戻す」。この線引きが、品質と速度を両立させる鍵でした。
再試行は 429 と 5xx だけに絞る
本番でまとまった枚数を回すと、必ず一時的なエラーに当たります。ここで全部のエラーをリトライすると、本来直すべきリクエスト不備(400)まで延々と叩き続けてしまいます。再試行するのはレート超過(429)とサーバー側の一時障害(500/503)だけに限定するのが実務的です。
async function generateWithRetry(params, maxRetries = 3) {
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const res = await ai.models.generateContent(params);
return res.text;
} catch (error) {
const status = error?.status ?? error?.response?.status;
// 入力不備は何度送り直しても通らない。即座に投げ返す
if (status === 400) throw error;
// レート超過・一時障害のみ指数バックオフで待つ
if (status === 429 || status === 500 || status === 503) {
const wait = 1000 * 2 ** (attempt - 1);
await new Promise((r) => setTimeout(r, wait));
continue;
}
throw error;
}
}
throw new Error(`${maxRetries}回試行しても完了しませんでした`);
}400 を即座に投げ返すようにしてから、ログが格段に読みやすくなりました。エラーの大半が「リトライ待ち」で埋まっていた状態から、本当に直すべき入力不備が見えるようになったのです。
もうひとつ、バックオフの待ち時間は「間に合わせ」で構いません。1 秒・2 秒・4 秒。個人開発のバッチは深夜に回るので、数秒の遅れは誰も困らない。ここを凝って可変ジッターまで実装しかけて、途中で手を止めました。効果の出ない場所に時間を使わない判断も、ひとりで回す以上は必要です。
コストは見積もりより実測の累計
最後に、地味ですが効いたのがコストの累計トラッキングです。1 枚あたりの単価は小さくても、数千枚を月に何度も回せば無視できません。レスポンスの usageMetadata には実際に消費したトークン数が入っているので、見積もりではなくこれを合計していくのが正確です。
async function analyzeAndCount(params, counter) {
const res = await ai.models.generateContent(params);
const usage = res.usageMetadata;
counter.totalTokens += usage?.totalTokenCount ?? 0;
counter.images += 1;
return { text: res.text, tokens: usage?.totalTokenCount };
}counter.images を一緒に持っておくと、1 枚あたりの平均トークン数が出せます。これが解像度を変えたときの効果を確かめる、いちばん素直なものさしになりました。長辺を 1024px に落としたときに平均が目に見えて下がるのを見て、はじめて縮小処理を信用できたのです。
私は月初にこの累計をリセットし、AdMob の売上と並べて見ています。分類自動化にかかった API 費用が、節約できた作業時間と広告収益に対して見合っているか。この一枚の比較表が、機能を続けるか畳むかの判断材料になっています。
次に試すなら
まずは手元の画像 10 枚で、responseSchema に category と confidence を宣言し、gemini-flash-latest に返させるところから始めてみてください。正規表現でフェンスを剥がす処理を書かずに済む感覚を、最初に味わっておくと戻れなくなります。
そこから、長辺 1024px への縮小を挟んで平均トークン数の変化を見る。しきい値を 0.5 で置いて、レビュー箱に上がってきたものを眺める。この順番で進めれば、枚数を増やしても壊れにくい形に自然と寄っていきます。
旧 SDK のまま眠っているスクリプトをお持ちの方は、移行のついでに組み直すのが結局いちばん早いかもしれません。私自身そうでした。同じように大量のアセットを抱えている方の、参考になれば幸いです。