「自転車に乗るペリカンをSVGで描いて」というプロンプトでLLMを比べる遊びは、すっかり定番になりました。ただ、何十枚も集め始めると、すぐに「で、これは誰がどう採点するのか」という問題にぶつかります。目視は疲れるうえに日によって基準がぶれますし、総合点で「8点」とつけても、あとから理由を説明できません。
この記事では、LLMが出力したSVGを機械的に、再現可能な形で採点するための最小パイプラインをPythonで組みます。
- 応答からSVGを取り出し、形式・リソース制約を静的にチェックする
- cairosvgで固定条件のPNGに描画し、同じ入力から同じ画素が出ることを確かめる
- 「DOMを数える」採点がなぜ危ういかを実際に確かめる
- 部品IDを指定した課題で、足とペダルの距離を描画結果から測る
- 判定を pass / fail / uncertain / not_applicable で記録し、集計時に未決を消さない
先に限界を書いておくと、このパイプラインは「鳥が正しく自転車に乗っているか」という意味的な判定を自動化するものではありません。自動化するのは、根拠を示せる部分(形式・描画・幾何の補助測定)と記録です。意味的な判定は人手か視覚Judgeに回し、その入力と結果を残すための土台を作る、という位置づけです。
環境
python -m venv .venv && . .venv/bin/activate
pip install cairosvg lxml numpy scipy pillow
動作確認は Python 3.13.5 / cairosvg 2.9.1 / cairo 1.18.4 / SciPy 1.18.1 で行いました。cairosvg はシステムの cairo ライブラリを使うので、無い場合は apt install libcairo2 などで入れてください。以下のコードはすべて手元で実行し、出力をそのまま貼っています。
0. 検証用のSVGを用意する
実在モデルの出力を貼る代わりに、検証用のSVGを手で書きます。部品には wheel-back や foot-l のようなIDを振っておきます(後半で使います)。LIFTED は足だけを transform で40px浮かせた版、MESSY_REPLY は「前置き+Markdownフェンス+script入り」という、実際によく見る崩れた応答の再現です。
# 検証用の手書きSVG(実在モデルの出力ではない)
BIKE = """
<circle id="wheel-back" cx="250" cy="450" r="90" fill="none" stroke="#333" stroke-width="8"/>
<circle id="wheel-front" cx="550" cy="450" r="90" fill="none" stroke="#333" stroke-width="8"/>
<path id="frame" d="M250 450 L400 450 L480 330 L330 330 Z M400 450 L330 330 M480 330 L550 450"
fill="none" stroke="#c0392b" stroke-width="8"/>
<line id="crank-l" x1="400" y1="450" x2="430" y2="490" stroke="#555" stroke-width="6"/>
<rect id="pedal-l" x="415" y="488" width="34" height="8" fill="#222"/>
<rect id="seat" x="305" y="312" width="60" height="12" rx="5" fill="#222"/>"""
PELICAN = """
<ellipse id="body" cx="350" cy="270" rx="70" ry="45" fill="#f4f4f4" stroke="#333" stroke-width="3"/>
<path id="neck" d="M400 245 Q430 180 410 150" fill="none" stroke="#333" stroke-width="14"/>
<circle id="head" cx="410" cy="140" r="22" fill="#f4f4f4" stroke="#333" stroke-width="3"/>
<path id="beak" d="M428 135 L520 160 L428 152 Z" fill="#f39c12" stroke="#333" stroke-width="2"/>
<line id="leg-l" x1="360" y1="310" x2="430" y2="482" stroke="#e67e22" stroke-width="6"/>"""
FOOT_ON = '<ellipse id="foot-l" cx="432" cy="482" rx="16" ry="6" fill="#e67e22"/>'
FOOT_OFF = '<ellipse id="foot-l" cx="432" cy="482" rx="16" ry="6" fill="#e67e22" transform="translate(0,-40)"/>'
def svg(*parts):
return ('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">'
+ "".join(parts) + "\n</svg>")
GOOD = svg(BIKE, PELICAN, FOOT_ON) # 足がペダルに乗っている
LIFTED = svg(BIKE, PELICAN, FOOT_OFF) # transformで足だけ40px浮かせた
# Markdownフェンス付き+scriptを含む「よくある崩れた応答」
MESSY_REPLY = "はい、どうぞ!\n```svg\n" + svg(BIKE, PELICAN, FOOT_ON,
'<script>alert(1)</script>') + "\n```\n楽しんでください。"
1. 抽出と静的チェック(F1)
最初に決めておくべきなのは、静的チェックで何を判定し、何を判定しないかです。SVGのソースから確実に言えるのは「指定どおりの形式か」「禁止した機能を使っていないか」までです。ここでは、課題文で次のように指定した想定にします。
- 自己完結した静的SVGだけを出力する。viewBox は
0 0 800 600 - 埋め込みビットマップ、外部リソース、script、foreignObject、アニメーションは使わない
import re
from lxml import etree
SVG_NS = "{http://www.w3.org/2000/svg}"
FORBIDDEN = {"script", "foreignObject", "image", "animate", "animateTransform",
"animateMotion", "set", "iframe"}
def extract_svg(reply: str):
"""応答テキストからSVGを取り出す。素のSVG以外なら repaired=True を返す。"""
text = reply.strip()
if text.startswith("<svg") and text.endswith("</svg>"):
return text, False
m = re.search(r"<svg\b.*?</svg>", text, flags=re.S)
return (m.group(0), True) if m else (None, True)
def static_check(svg_text: str, viewbox="0 0 800 600"):
"""F1(形式・視口・リソース制約)だけを判定する。絵の良し悪しは見ない。"""
issues = []
parser = etree.XMLParser(resolve_entities=False, no_network=True,
load_dtd=False, huge_tree=False)
try:
root = etree.fromstring(svg_text.encode(), parser)
except etree.XMLSyntaxError as e:
return "fail", [f"XMLとして解析できない: {e}"]
if root.tag != SVG_NS + "svg":
issues.append(f"ルート要素がsvgではない: {root.tag}")
if root.get("viewBox") != viewbox:
issues.append(f"viewBoxが {root.get('viewBox')!r}")
for el in root.iter():
if not isinstance(el.tag, str):
continue # コメント・処理命令は飛ばす
name = etree.QName(el).localname
if name in FORBIDDEN:
issues.append(f"禁止要素 <{name}>")
for attr, val in el.attrib.items():
a = etree.QName(attr).localname
if a.startswith("on"):
issues.append(f"イベント属性 {a}= on <{name}>")
if a == "href" and not val.startswith("#"):
issues.append(f"外部参照 href={val[:40]!r}")
return ("fail" if issues else "pass"), issues
if __name__ == "__main__":
from samples import GOOD, MESSY_REPLY
for label, reply in [("GOOD", GOOD), ("MESSY_REPLY", MESSY_REPLY)]:
svg_text, repaired = extract_svg(reply)
status, issues = static_check(svg_text)
print(f"{label}: repaired={repaired} F1={status} {issues}")
GOOD: repaired=False F1=pass []
MESSY_REPLY: repaired=True F1=fail ['禁止要素 <script>']
ポイントは3つです。
-
抽出したことを記録する。 フェンスを剥がせば描画はできますが、それは「初回応答そのもの」ではありません。
repaired=Trueを残しておけば、「修復後なら描ける」と「最初から仕様どおり」を後で区別できます。 -
パーサを安全側に設定する。 モデルの出力は信頼できない入力です。
resolve_entities=Falseとno_network=Trueで、XXEやネットワークアクセスの経路を閉じておきます。 - F1は絵の評価ではない。 F1がpassでも「ペリカンが自転車に乗っている」ことは何も保証されません。逆に、F1がfailでも絵としては完璧なことがあります。両者は別の列に記録します。
2. 固定条件で描画し、再現性を確かめる
次はPNGへの描画です。採点の再現性を一番崩しやすいのは、実はモデルではなく描画環境です。ビューポートのサイズ、背景色、レンダラーのバージョンが違えば、同じSVGでも画素が変わります。そこで描画条件を関数の中に固定し、その条件自体もプロファイルとして記録します。
import hashlib, json, platform
import cairosvg, cairocffi, numpy as np
from io import BytesIO
from PIL import Image
W, H = 800, 600 # 視口と同じ固定サイズで描く
def render(svg_text: str) -> np.ndarray:
# unsafe=False(既定値): 外部エンティティを禁止し、data: 以外のURLは読みに行かない
png = cairosvg.svg2png(bytestring=svg_text.encode(), output_width=W,
output_height=H, background_color="white", unsafe=False)
return np.asarray(Image.open(BytesIO(png)).convert("RGB"))
def ink_ratio(img: np.ndarray) -> float:
"""白以外のピクセルの割合。0なら「描画されたが中身が空」。"""
return float((img < 250).any(axis=2).mean())
def render_profile() -> dict:
return {"renderer": f"cairosvg {cairosvg.__version__}",
"cairo": cairocffi.cairo_version_string(),
"python": platform.python_version(), "size": f"{W}x{H}", "background": "white"}
if __name__ == "__main__":
from samples import GOOD
a, b = render(GOOD), render(GOOD)
digest = lambda x: hashlib.sha256(x.tobytes()).hexdigest()[:16]
print("shape:", a.shape, " ink ratio:", round(ink_ratio(a), 4))
print("2回描画して同一:", digest(a) == digest(b), digest(a))
print(json.dumps(render_profile(), ensure_ascii=False))
shape: (600, 800, 3) ink ratio: 0.0714
2回描画して同一: True 402e7d866e8b1b52
{"renderer": "cairosvg 2.9.1", "cairo": "1.18.4", "python": "3.13.5", "size": "800x600", "background": "white"}
同じSVGを2回描画すると、画素配列のハッシュが一致しました。採点結果を記録するときは、SVGのハッシュに加えてこのプロファイルを必ず一緒に保存します。レンダラーを更新したら、保存済みのSVGを新旧両方で描画して差分を確認してから切り替えます。そうしないと、モデルは何も変わっていないのに「スコアが落ちた」という偽の変化が生まれます。
cairosvg は unsafe=False(既定値)だと外部エンティティを禁止し、data: 以外のURLは読みに行きません。ただしそれに頼り切らず、1章の静的チェックで外部参照を先に弾いておくのが安全です。また、cairosvg は script を実行せず、アニメーションも再生しません(静止状態で描画されます)。今回は静的課題なのでこれで十分ですが、アニメーション課題には向きません(後述)。
3. 「circleを2つ数えたら車輪2つ」は成り立たない
SVGはソースがあるので、「<circle> が2つあれば車輪が2つ」「foot というIDがあれば足がある」と判定したくなります。試してみましょう。同じ2つの車輪を、circle・path・use の3通りで書いて比べます。
from lxml import etree
from render import render
# 同じ車輪を3通りで書く: circle / path / use
CIRCLES = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">
<circle cx="250" cy="450" r="90" fill="none" stroke="#333" stroke-width="8"/>
<circle cx="550" cy="450" r="90" fill="none" stroke="#333" stroke-width="8"/></svg>"""
PATHS = """<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 600">
<path d="M160 450 A90 90 0 1 0 340 450 A90 90 0 1 0 160 450 Z
M460 450 A90 90 0 1 0 640 450 A90 90 0 1 0 460 450 Z"
fill="none" stroke="#333" stroke-width="8"/></svg>"""
USE = """<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
viewBox="0 0 800 600"><defs><circle id="w" r="90" fill="none" stroke="#333" stroke-width="8"/></defs>
<use xlink:href="#w" x="250" y="450"/><use xlink:href="#w" x="550" y="450"/></svg>"""
base = render(CIRCLES).astype(int)
for name, s in [("circle", CIRCLES), ("path", PATHS), ("use", USE)]:
n = len(etree.fromstring(s.encode()).findall(".//{http://www.w3.org/2000/svg}circle"))
diff = (abs(render(s).astype(int) - base) > 64).any(axis=2).mean()
print(f"{name:>6}: <circle>の数={n} circle版との差分ピクセル={diff:.3%}")
circle: <circle>の数=2 circle版との差分ピクセル=0.000%
path: <circle>の数=0 circle版との差分ピクセル=0.000%
use: <circle>の数=1 circle版との差分ピクセル=0.000%
描画結果は3つとも(しきい値の範囲で)同一なのに、<circle> の数は2・0・1とばらばらです。SVGでは、1つの見た目を表すDOMの書き方がいくらでもあります。DOMを数える規則は「書き方」を見ているだけで、「画面に何が描かれているか」は見ていません。IDの名前もモデルが自分でつけたラベルにすぎず、独立した検証ではありません。
4. 部品IDを指定した課題なら、描画結果から接触を測れる
とはいえ、幾何の測定がまったく使えないわけではありません。課題文で「足は foot-l、ペダルは pedal-l、後輪は wheel-back というIDにする」と出力形式を指定したトラックを別に設ければ、補助的な測定ができます。
ここでの工夫は、包囲矩形(bbox)をDOMから自前で計算しないことです。自前でやると transform や入れ子の座標系を自分で解決する必要があり、しかも他の要素に隠れて「見えていない部分」まで含んでしまいます。代わりに、その要素を消して描画し直し、色が変わったピクセルを「画面上で実際に見えている領域」とみなします。座標変換はレンダラーが解決してくれます。
import numpy as np
from lxml import etree
from scipy.ndimage import distance_transform_edt
from render import render
def footprint(svg_text: str, el_id: str):
"""その要素を消すと色が変わるピクセル=画面上で実際に見えている領域。
transform・use・重なり順を、レンダラーに解決させる。"""
root = etree.fromstring(svg_text.encode())
hits = root.xpath("//*[@id=$i]", i=el_id)
if not hits:
return None
full = render(svg_text).astype(int)
hits[0].getparent().remove(hits[0])
without = render(etree.tostring(root, encoding="unicode")).astype(int)
return (abs(full - without) > 32).any(axis=2)
def contact_gap(svg_text, a="foot-l", b="pedal-l", scale_ref="wheel-back"):
ma, mb, mw = (footprint(svg_text, i) for i in (a, b, scale_ref))
if ma is None or mb is None or mw is None or not ma.any() or not mb.any():
return "uncertain", None # IDが無い・見えていない → 判定しない
gap_px = distance_transform_edt(~mb)[ma].min() # aの可視画素からbまでの最短距離
cols = np.where(mw.any(axis=0))[0]
wheel_px = cols.max() - cols.min() + 1 # 後輪の見かけの直径
return "measured", round(float(gap_px / wheel_px), 3)
if __name__ == "__main__":
from samples import GOOD, LIFTED
for name, s in [("GOOD", GOOD), ("LIFTED", LIFTED)]:
print(name, contact_gap(s))
print("IDなし", contact_gap(GOOD.replace('id="foot-l"', 'id="toe"')))
GOOD ('measured', 0.005)
LIFTED ('measured', 0.218)
IDなし ('uncertain', None)
LIFTED は cy の値を変えずに transform だけで足を浮かせていますが、描画結果ベースなので正しく離れていると測れています。距離は後輪の見かけの直径で割った相対値にしてあるので、絵の大きさが違っても比べられます。
ただし、これはあくまで補助証拠です。
- 「どこまで近ければ接触とみなすか」の閾値は、人手でラベルを付けたサンプルで校準するまで判定に使わない
- 2次元で近い・重なっているからといって、支えている・踏んでいるとは限らない(前後の遮蔽もありうる)
- IDが無い、または見えていない場合は推測せず
uncertainを返す
「モデル自身が foot-l と名付けたものが本当に足に見えるか」は、別途人手や視覚Judgeで確認する必要があります。
5. 4状態で記録し、集計で未決を消さない
各チェック項目は pass / fail だけでなく、uncertain(証拠不足) と not_applicable(課題に含まれない) を区別して持たせます。静的課題でアニメーション項目が not_applicable なのは減点対象ではありません。一方で、アニメーション課題なのに判断材料が足りないなら uncertain で、自動的にpassにはしません。
from collections import Counter
def verdict(checks: dict) -> str:
"""必須条件の集約: 明確なfailが1つでもあればfail、無ければuncertainが残る限り保留。"""
required = [v for v in checks.values() if v != "not_applicable"]
if "fail" in required:
return "fail"
if "uncertain" in required:
return "uncertain"
return "pass"
print(verdict({"F1": "pass", "B1": "pass", "R2": "fail", "R3": "uncertain", "A": "not_applicable"}))
print(verdict({"F1": "pass", "B1": "pass", "R2": "uncertain", "A": "not_applicable"}))
# 例示用: 予定100リクエストのうち 合格72・不合格18・未決10 だった場合
c = Counter(pass_=72, fail=18, uncertain=10)
n = sum(c.values())
lo, hi = c["pass_"] / n, (c["pass_"] + c["uncertain"]) / n
print(f"合格率の範囲: {lo:.0%}〜{hi:.0%}(未決を全部不合格/全部合格とした場合)")
print(f"未決を捨てた場合: {c['pass_'] / (c['pass_'] + c['fail']):.0%} ← これを単独で報告しない")
fail
uncertain
合格率の範囲: 72%〜82%(未決を全部不合格/全部合格とした場合)
未決を捨てた場合: 80% ← これを単独で報告しない
必須条件に明確なfailが1つでもあれば不合格です。failが無くてもuncertainが残っていれば保留にして、レビューに回します。配色や細部の美しさは別の列に記録し、必須条件のfailを打ち消す材料にはしません。
集計では、未決の10件を捨てて「合格率80%」とだけ報告しがちですが、それだと見かけ上の数字が良くなります。確定済みのラベルを正しいとみなすなら、真の値は72%〜82%の範囲にあります。なお、これは未決ラベルによる幅であって統計的な信頼区間ではなく、Judgeの誤判定は含んでいません。
6. 1件ぶんの記録をまとめる
最後に、ここまでの部品をつなげて1件の記録を作ります。MESSY_REPLY を入れてみます。
import hashlib, json
from checks import extract_svg, static_check
from render import render, ink_ratio, render_profile
from contact import contact_gap
from samples import MESSY_REPLY
reply = MESSY_REPLY # モデルの生の応答
svg_text, repaired = extract_svg(reply)
f1, issues = static_check(svg_text)
img = render(svg_text) # 診断用に描画はする(scriptは実行されない)
status, gap = contact_gap(svg_text)
record = {
"run_id": "example-run-001",
"task_version": "pelican-static-example-v1",
"rubric_version": "pelican-static-example-v1",
"reply_sha256": hashlib.sha256(reply.encode()).hexdigest()[:16],
"svg_sha256": hashlib.sha256(svg_text.encode()).hexdigest()[:16],
"png_sha256": hashlib.sha256(img.tobytes()).hexdigest()[:16],
"render_profile": render_profile(),
"repaired": repaired, # 抽出が必要だった=首答そのままではない
"checks": {
"F1": {"status": f1, "evidence": issues},
"render_nonempty": {"status": "pass" if ink_ratio(img) > 0.01 else "fail"},
"R2_gap_aux": {"status": status, "gap_over_wheel": gap,
"note": "補助証拠。閾値は人手ラベルで校準するまで判定に使わない"},
"A": {"status": "not_applicable"},
},
}
print(json.dumps(record, ensure_ascii=False, indent=1))
{
"run_id": "example-run-001",
"task_version": "pelican-static-example-v1",
"rubric_version": "pelican-static-example-v1",
"reply_sha256": "2f97f2d9fde97f68",
"svg_sha256": "dfd47ad6b731d37a",
"png_sha256": "402e7d866e8b1b52",
"render_profile": {
"renderer": "cairosvg 2.9.1",
"cairo": "1.18.4",
"python": "3.13.5",
"size": "800x600",
"background": "white"
},
"repaired": true,
"checks": {
"F1": {
"status": "fail",
"evidence": [
"禁止要素 <script>"
]
},
"render_nonempty": {
"status": "pass"
},
"R2_gap_aux": {
"status": "measured",
"gap_over_wheel": 0.005,
"note": "補助証拠。閾値は人手ラベルで校準するまで判定に使わない"
},
"A": {
"status": "not_applicable"
}
}
}
この記録から読み取れることを整理します。
-
png_sha256が2章のGOODと一致しています。script を足しても描画結果は変わらない、つまり描画できるかどうかとF1の判定は独立していることが記録上で確認できます。 -
repaired: trueとF1: failが残っているので、「絵は描けているが、初回応答としては仕様違反」と後から説明できます。 - 応答・SVG・PNGのハッシュ、課題とルーブリックのバージョン、描画プロファイルが揃っているので、ルーブリックやレンダラーを更新したときに同じ成果物を再採点し、「同一成果物の新版による再採点」として旧記録と並べて残せます(旧記録は上書きしない)。
実運用で足りないもの
- サンドボックス。 cairosvg に任せる場合でも、採点プロセスはネットワークや認証情報から隔離し、タイムアウトとメモリ上限を付けたサブプロセスで動かすべきです。巨大なpathや深い入れ子で、レンダラーを長時間占有するSVGもありえます。
-
アニメーション。 cairosvg は静止画しか出さないので、アニメーション課題にはブラウザでの描画が必要です。その場合は時刻を指定してフレームを取得する方法を、動きが既知のテストファイルで先に検証してください。たとえば Playwright のスクリーンショットの
animations="disabled"は、有限のアニメーションを終端まで進め、無限のものは初期状態に戻す処理です。「任意の時刻を撮る」機能ではありません。 - 意味的な判定。 「ペリカンに見えるか」「サドルに座っているか」は、人手のリファレンスで校準した視覚Judgeに、項目ごとの質問として聞くのが現実的です。校準では全体の一致率だけでなく、重大な誤りの見逃し率と、正しい絵を誤って落とす率を分けて見ます。
まとめ
- 静的チェックは「形式と禁止機能」に絞り、絵の良し悪しとは別の列に記録する
- 描画条件を固定し、その条件自体をハッシュと一緒に保存する
- DOMの要素数やID名は意味の証拠にならない。幾何を測るなら描画結果から測る
- pass / fail / uncertain / not_applicable を区別し、集計で未決を消さない
採点器を作ってみると、モデルを比べる前に「物差し」の方が何度も変わることに気づきます。物差しのバージョンを記録しておくことが、「モデルが劣化した」のか「採点が変わった」のかを見分ける一番の近道でした。
本記事の採点設計(必須条件と付加項目の分離、4状態の判定、未決の扱い)は、私が関わっている Folkbench のブログで書いたペリカン採点の記事の考え方をベースに、コードに落としたものです。Folkbench は、同じモデルを提供する公式APIや中継サービスを、公開した同じ指標で比べるランキングサイトです。各結果にはテスト日時が表示されます。サイト上のペリカン作品展は「趣味の対照実験であり、能力評価には使えず、スコアやランキングには入らない」と明記しているので、上の採点器もあくまで考え方の共有としてお読みください。既存の比較結果から見たい方はこちらです: https://folkbench.com/?utm_source=luntan&utm_campaign=qiita
開示: 筆者は Folkbench の開発に関わっています。本記事はAIの支援を受けて執筆し、コードはすべて筆者の環境で実行して出力を確認しています。記事中の数値は掲載コードの実行結果または説明用の例であり、実在のモデルやサービスの測定値ではありません。