作家さんから作品台帳のエクセルが届いた午後、私はサイズの列を上から下まで眺めて、いちど手を止めました。受託で作らせていただいている曼荼羅作家さんの公式サイトは、作品の追加も価格の変更も、この台帳ひとつを正本にして反映する作りにしております。タイトルも価格も販売状態も、そのまま流し込めばよい列でした。
サイズの列だけが違いました。「F6号」の隣に「41×31.8cm」があり、その下に「A4くらい」があり、さらに下には「30cm角」があります。どれも作家さんにとっては同じ意味の言葉で、私にとっては四つの違う書式でした。
サイトには「W × H cm」の一列しかありません。四十行ほどを手で直すのは一時間で済みますが、次の納品でまた同じ一時間が来ます。それで、Gemini API に読ませる小さな道具を一本書くことにしました——ただし、書く前に一つだけ線を引いてからです。
最初にお伝えしたいのは、換算をモデルに頼まなかったことです
最初の版では、私は素直に「この文字列を cm に直してください」と頼んでおりました。返ってくる数字はもっともらしく、たいていは合っています。困ったのは、合っていない行が混じっていても、見た目では合っている行と区別がつかないことでした。「F8号」が「45.5 × 38.0」で返るか「45.5 × 39.0」で返るかは、私が号数表を横に開いて照らし合わせないかぎり分かりません。四十行を毎回照らし合わせるなら、はじめから手で直したほうが早いのです。
そこで、頼む内容を「換算」から「読み取り」に切り替えました。文字列が号数なのか、cm の直書きなのか、紙の規格なのか、それとも読めないのか——モデルに答えてもらうのはそこまでです。cm への換算は、私の手元の号数表で引きます。表は間違えようがありません。
読み取りは Gemini に、換算は表に、向きは画像に。 この線引きを先に決めてから書き始めたので、コードは短く済みました。
台帳のサイズ欄に、実際に何が書かれていたか
書式を数えてみると、こうなっておりました。
| 書き方 | 例 | 割合(およそ) | 必要な処理 |
| 号数 | F6号/F10/S6 号 | 約 40% | 号数表で引く |
| cm の直書き | 41×31.8cm/30cm角 | 約 35% | 数字をそのまま転記 |
| 紙の規格 | A4/B5 | 約 15% | 規格表で引く |
| あいまい | A4くらい/ハガキ大 | 約 10% | 読み取って要確認に回す |
「号」の有無、全角と半角、「×」と「x」と「*」——表記の揺れは、この四種類の中でもさらに枝分かれしていました。正規表現で書き始めて、三つ目の分岐で手が止まったのが、モデルに読ませようと決めた瞬間です。
渡す前に一つ、下ごしらえがあります。作家さんの台帳は先頭が二行のヘッダで、結合セルもありました。この形のまま渡すと、行の対応が崩れるところから壊れます。その話は「Gemini に渡す前に表が壊れる場所は結合セルと2行ヘッダ」に書きましたので、ここでは「行番号とサイズ文字列の二列」に平らにしてから渡す、とだけ書き残します。
手順 1: 読み取りの「型」を先に決めます
モデルに自由に答えさせると、行ごとに答えの形が変わります。先に型を決めて、その型でしか返せないようにします。Gemini API の Structured Output は Pydantic のモデルをそのまま response_schema に渡せますので、型の定義がそのまま仕様書になります(公式の説明は「Structured output」にあります)。
# size_schema.py — 読み取り結果の型。換算はここに含めない
from enum import Enum
from typing import Optional
from pydantic import BaseModel, Field
class Kind(str, Enum):
canvas = "canvas" # F6号・P10 などの号数
paper = "paper" # A4・B5 などの紙の規格
cm = "cm" # 41×31.8cm のような直書き
unknown = "unknown" # 読めない・判断できない
class SizeRead(BaseModel):
row_id: int = Field(description="入力の行番号をそのまま返す")
kind: Kind
series: Optional[str] = Field(
default=None, description="F/P/M/S または A/B。kind が canvas/paper のときだけ"
)
number: Optional[int] = Field(
default=None, description="号数または規格の数字。kind が canvas/paper のときだけ"
)
first_cm: Optional[float] = Field(
default=None, description="kind が cm のとき、書かれていた順に一つ目の数字"
)
second_cm: Optional[float] = Field(
default=None, description="kind が cm のとき、二つ目の数字。『30cm角』なら一つ目と同じ"
)
approximate: bool = Field(description="『くらい』『約』『程度』が付いていれば true")
note: str = Field(description="迷った点。なければ空文字")
width と height という名前を避けて first_cm と second_cm にしたのは、わざとです。台帳の「41×31.8」が幅×高さなのか高さ×幅なのか、この時点では誰にも分かりません。名前で決めつけない、というだけの話なのですが、後の手順で効いてきます。
手順 2: 四十行を一度に読ませ、返ってきた行を数えます
一行ずつ呼ぶと四十回の往復になります。行番号を付けて一度に渡せば一回で済み、費用も 3.8 Flash の導入価格(100万トークンあたり入力 0.75 ドル・出力 3.75 ドル)で計算すると、この規模なら一回あたり 1 セントに届きません。
一度に渡すときに、私が必ず入れているのが「返ってきた件数と行番号の照合」です。モデルは、行を一つ落としたり、同じ行を二度返したりすることがあります。件数が合っているのに行番号が重複している、という壊れ方もあります。個人開発で回している他のバッチでも、この照合だけは省いておりません。
# read_sizes.py — 一回の呼び出しで全行を読み、行番号を突き合わせる
import os
from google import genai
from google.genai import types
from pydantic import TypeAdapter
from size_schema import SizeRead
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
MODEL = "gemini-3.8-flash"
INSTRUCTION = """あなたは作品台帳のサイズ欄を読み取る係です。
各行の文字列がどの種類の書き方かを判定し、書かれている数字を転記してください。
cm への換算はしないでください。号数や紙の規格は series と number に分けるだけです。
「くらい」「約」「程度」が付いていれば approximate を true にしてください。
入力の行はすべて返し、row_id は入力の番号をそのまま使ってください。"""
def read_sizes(rows: list[tuple[int, str]]) -> list[SizeRead]:
body = "\n".join(f"{row_id}\t{raw}" for row_id, raw in rows)
res = client.models.generate_content(
model=MODEL,
contents=f"{INSTRUCTION}\n\n行番号\tサイズ欄\n{body}",
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=list[SizeRead],
temperature=0,
),
)
parsed = TypeAdapter(list[SizeRead]).validate_json(res.text)
# 送った行と返った行を突き合わせる。ここを省くと欠けたまま先へ進む
sent = {row_id for row_id, _ in rows}
got = [p.row_id for p in parsed]
if sorted(got) != sorted(sent):
missing = sent - set(got)
dup = {r for r in got if got.count(r) > 1}
raise RuntimeError(f"行の対応が崩れました missing={missing} dup={dup}")
return sorted(parsed, key=lambda p: p.row_id)
temperature=0 にしているのは、同じ台帳を来週読ませても同じ答えが返ってきてほしいからです。読み取りに創造性は要りません。
もう一つ、res.parsed ではなく res.text を自分で検証しているのは、スキーマに通ったあとでも「kind が canvas なのに number が空」のような、型としては正しく意味としては壊れている答えが通ってくるからです。次の手順で、その意味の側を確かめます。
手順 3: 号数表と画像の縦横で cm を確定します
換算は表で引きます。私の台帳に出てきた範囲だけを載せておりますので、皆さまの作家さんの作品に合わせて行を足していただければと思います。値は日本の画布の規格(長辺 × 短辺)です。
# convert.py — 換算は表で、向きは画像で決める
from pathlib import Path
from typing import Optional
from PIL import Image
from size_schema import SizeRead, Kind
# 号数表: (series, number) -> (長辺 cm, 短辺 cm)
CANVAS = {
("F", 0): (18.0, 14.0), ("F", 3): (27.3, 22.0), ("F", 4): (33.3, 24.2),
("F", 6): (41.0, 31.8), ("F", 8): (45.5, 38.0), ("F", 10): (53.0, 45.5),
("F", 12): (60.6, 50.0), ("F", 15): (65.2, 53.0), ("F", 20): (72.7, 60.6),
("F", 30): (91.0, 72.7),
("P", 6): (41.0, 27.3), ("P", 10): (53.0, 41.0),
("M", 6): (41.0, 24.2), ("M", 10): (53.0, 33.3),
("S", 6): (41.0, 41.0), ("S", 10): (53.0, 53.0),
}
PAPER = {
("A", 3): (42.0, 29.7), ("A", 4): (29.7, 21.0), ("A", 5): (21.0, 14.8),
("B", 4): (36.4, 25.7), ("B", 5): (25.7, 18.2),
}
def pair_cm(r: SizeRead) -> Optional[tuple[float, float]]:
"""読み取り結果から (長辺, 短辺) を返す。引けなければ None"""
if r.kind == Kind.cm and r.first_cm and r.second_cm:
a, b = float(r.first_cm), float(r.second_cm)
return (max(a, b), min(a, b))
if r.kind == Kind.canvas and r.series and r.number is not None:
return CANVAS.get((r.series.upper(), r.number))
if r.kind == Kind.paper and r.series and r.number is not None:
return PAPER.get((r.series.upper(), r.number))
return None
def with_orientation(pair: tuple[float, float], image: Path) -> tuple[float, float]:
"""画像の縦横比で (幅, 高さ) の順に並べ替える"""
long_cm, short_cm = pair
with Image.open(image) as im:
w, h = im.size
return (long_cm, short_cm) if w >= h else (short_cm, long_cm)
pair_cm が None を返す行は、表にない号数か、モデルが読めなかった行です。この場合は例外で止めず、None のまま先へ流し、最後にまとめて「要確認」として出します。四十行のうち三行が要確認になるくらいなら、その三行だけ作家さんに聞けばよいのです。
手順 4: 台帳から JSON まで通します
最後に、エクセルを読んで、読み取り・換算・向きの三段を通し、サイトが読む JSON と要確認の一覧を書き出します。
# build.py — 台帳 → 読み取り → 換算 → JSON
import json
from pathlib import Path
from openpyxl import load_workbook
from read_sizes import read_sizes
from convert import pair_cm, with_orientation
LEDGER = Path("作品管理.xlsx")
IMAGES = Path("images") # {作品ID}.jpg を置く
OUT = Path("artworks.json")
wb = load_workbook(LEDGER, data_only=True)
ws = wb["作品一覧"]
rows, meta = [], {}
for row_id, row in enumerate(ws.iter_rows(min_row=3, values_only=True), start=1):
artwork_id, title, size_raw = row[0], row[1], row[3]
if not artwork_id:
continue
rows.append((row_id, str(size_raw or "").strip()))
meta[row_id] = {"id": str(artwork_id), "title": str(title)}
reads = read_sizes(rows)
raw_by_id = dict(rows)
out, review = [], []
for r in reads:
m = meta[r.row_id]
pair = pair_cm(r)
image = IMAGES / f"{m['id']}.jpg"
if pair is None or not image.exists():
review.append({**m, "raw": raw_by_id[r.row_id], "note": r.note or "表にない・画像なし"})
continue
w_cm, h_cm = with_orientation(pair, image)
out.append({
**m,
"width_cm": w_cm,
"height_cm": h_cm,
"approximate": r.approximate, # サイトでは「約」を頭に付ける
"size_raw": raw_by_id[r.row_id], # 元の文字列も残す
})
OUT.write_text(json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"確定 {len(out)} 行 / 要確認 {len(review)} 行")
for item in review:
print(" -", item["id"], item["title"], "|", item["raw"], "|", item["note"])
size_raw を JSON に残しているのは、あとで作家さんから「そのサイズ、違います」と言われたときに、元の文字列まで遡れるようにするためです。換算した数字だけを残すと、間違いの出どころが分からなくなります。
approximate が true の行は、サイトの表示で「約 29.7 × 21.0 cm」のように頭に「約」を付けます。「A4くらい」を「29.7 × 21.0 cm」と言い切ってしまうと、作家さんの言葉より一段強い表現になってしまうからです。
直感に反していたこと: サイズ欄は、向きを教えてくれません
私がいちばん見落としていた落とし穴はここでした。「F6号」と書かれていても、その作品が縦なのか横なのかは、サイズ欄のどこにも書かれていません。号数表が教えてくれるのは長辺と短辺の組までで、どちらが幅なのかは別の情報が要ります。
最初、私はモデルに「向きも判定してください」と頼みかけました。しかし文字列しか渡していないのですから、モデルに分かるはずがありません。分からないものを聞けば、もっともらしい答えが返ってくるだけです。
向きを知っているのは、作品の画像でした。台帳と一緒に届く写真の縦横比を見れば、幅と高さの順は決まります。with_orientation が画像を開いているのはそのためで、モデルには一切関わらせていません。
要するに、この道具は三つの情報源を三つの役割に分けています。文字列の解釈はモデルが、数字の根拠は表が、向きは画像が、それぞれ得意なことだけを受け持っています。
つまずいた箇所と、そのとき見直したこと
書き始めから通しで動くまでに、三箇所でつまずきました。順に書き残します。
kind が canvas なのに number が空のまま返ってくること。スキーマ上は Optional なので通ってしまいます。対処として、pair_cm が None を返した行を要確認へ回す形にし、例外で止めないようにしました。止めてしまうと、残りの三十九行まで道連れになります。
- 「30cm角」の二つ目の数字が空で返ってくること。
second_cm の説明文に「『30cm角』なら一つ目と同じ」と一言足したところ、この揺れは収まりました。スキーマの description は、モデルへの指示として読まれます。
- 半角の「x」と全角の「×」で読み取りが揺れること。これはモデル側ではなく、私が台帳を平らにするときに文字を正規化していなかったのが原因でした。渡す前に
unicodedata.normalize("NFKC", raw) を通してから、揺れは出ておりません。
三つとも、モデルを賢くしようとするのではなく、渡す側と受け取る側の私の手元を直すことで収まりました。いま思えば、モデルに頼む範囲を「読み取り」に絞ったからこそ、直す場所が自分の側に残ったのだと思います。
費用の話を一つだけ添えます。3.8 Flash の導入価格は暦の 2026 年 12 月 31 日までで、翌日から標準価格に切り替わります。この道具の一回あたりの費用は小さいものですが、見積もりの書き方は「値上げの日をまたぐ費用の見積もりを、はじめから二段で書く」の二段の形にしております。
次にやること
まずは、皆さまの手元の台帳からサイズの列だけを十行ほど抜き出して、read_sizes に渡してみていただければと思います。返ってきた kind と note を眺めるだけで、その台帳にどんな書き方が混ざっているかが見えてきます。表を埋めるのはそのあとで十分です。
モデルに任せる範囲を先に決めてから書き始める——この順番だけは、次の受託でも守るようにしています。