AIで作った商品カバーの来歴情報はWebP変換で消える|C2PAとXMPを5通りで調べた

商品カバーを AI で作り、サイト用に WebP へ変換しました。見た目は何も変わりません。ところが、変換前の PNG にあった「Google AI で作成」という署名が、変換後のファイルから消えていました。
しかも、消えたのは気づきにくい形でした。画像は開けます。ファイルサイズも小さくなっています。確認しなければ、署名が付いていると思い込んだまま出荷していました。
今回は、生成直後の PNG を 5 通りの方法で WebP にしました。署名が残るかどうかを、同じ検査スクリプトで調べた記録です。
前回完了時から変えたこと
前回は、AI で作った EPUB の目次と参照を EPUBCheck で検査しました。対象は購入者が読む本文ファイルです。今回の対象は、販売ページに並べる表紙画像です。
| 時点 | 確認できること | 残る問題 |
|---|---|---|
| 前回完了時 | EPUBの構造と参照先を検査できます | 表紙画像の来歴情報は見ていません |
| 今回完了後 | 画像変換のあとに署名と生成表示が残るか検査できます | 販売先への申告は人が判断します |
読み飛ばせる章: C2PA と XMP の違いを知っている場合は、「5通りで変換して検査する」から進めます。
なぜ変換後に確認するのか
電子書籍の販売先には、AI 生成の画像を申告させるところがあります。Amazon KDP のコンテンツガイドラインは、AI が作った画像を AI-generated と定義し、表紙や挿絵も対象に含めています。AI で作った文章や画像に人が手を入れても、元の中身を AI ツールが作っていれば AI-generated の扱いです。
申告はフォームで行います。画像の中のメタデータが審査に使われるとは書かれていません。それでも、自分の手元にある画像が「AI 生成だと分かる状態」かどうかは、別の問題として残ります。
- 後から素材を見直すとき、どの画像が生成物か判別できます
- 外注先や共同作業者に渡すとき、生成物だと伝わります
- 画像を改ざんされたとき、署名が壊れて気づけます
最後の点は C2PA の役割です。XMP の印は、ただの文字情報です。署名はありません。
販売前の流れは次のとおりです。
[画像生成AIがPNGを返す]
│
▼
[cover-original.png として保存]
│
▼
[inspect_provenance.py で変換前を検査]
│
▼
[WebPへ変換: Pillow / ImageMagick / sharp]
│
▼
[同じスクリプトで変換後を検査]
┌────┴────┐
▼ ▼
印が残る 印が消える
│ └── 変換方法を変えて再実行
▼
[販売ページへ配置]
用語をそろえる
| 用語 | この回での意味 |
|---|---|
| C2PA | 画像の作成元や編集履歴を、署名つきで画像ファイルへ埋め込む仕様です |
| マニフェスト | C2PAが画像へ埋め込む、署名つきの記録のかたまりです |
| ハードバインディング | 画像のハッシュ値を記録し、画像と記録を結びつける仕組みです |
| XMP | 画像へ付けるXML形式のメタデータです。署名はありません |
| DigitalSourceType | 画像がどう作られたかを示すXMPの項目です。AI生成はtrainedAlgorithmicMediaです |
| 再エンコード | 画像を一度デコードして、別の形式で書き直す処理です |
| 信頼リスト | 署名者の証明書を信用してよいかを判断する、証明書の一覧です |
検査用の環境を作る
作業フォルダを新しく作り、Python と Node.js のパッケージを入れます。使った版は Python 3.13.5、Node.js 22.23.2、ImageMagick 7.1.1-43 です。
mkdir cover-provenance-lab
cd cover-provenance-lab
python3 -m venv .venv
. .venv/bin/activate
pip install -q c2pa-python==0.38.0 pillow==12.3.0
pip list 2>/dev/null | grep -E "^(c2pa-python|pillow) "
python --version
c2pa-python 0.38.0
pillow 12.3.0
Python 3.13.5
sharp も入れます。
npm init -y >/dev/null 2>&1
npm install [email protected] 2>&1 | grep -E "added|error"
added 11 packages, and audited 12 packages in 859ms
入力にするcover-original.pngは、google/gemini-3.1-flash-imageを OpenRouter 経由で呼び、16:9 で依頼して返ってきた PNG です。変換せず、返されたバイト列をそのまま保存しました。API キーの扱いはこの記事の範囲外なので省きます。手元に別の生成 PNG があれば、同じ名前で置いて試せます。
sha256sum cover-original.png
identify cover-original.png
6167c6830b07689f4db2a6c8b393e63ae94ea1c29ef7426fe7b2a6feb8bd9af9 cover-original.png
cover-original.png PNG 1376x768 1376x768+0+0 8-bit sRGB 1.1881MiB 0.000u 0:00.000
検査スクリプトを書く
保存したinspect_provenance.pyは次の内容です。C2PA のマニフェストを読み、署名者と検証状態を出します。XMP は、AI 生成を示す語trainedAlgorithmicMediaがファイルに含まれるかだけを見ます。
from __future__ import annotations
import io
import json
import sys
from pathlib import Path
from c2pa import Reader
from PIL import Image
MIME = {".png": "image/png", ".webp": "image/webp"}
AI_MARK = b"trainedAlgorithmicMedia"
def c2pa_state(path: Path) -> str:
try:
reader = Reader(MIME[path.suffix], io.BytesIO(path.read_bytes()))
except Exception as exc:
return f"none ({type(exc).__name__})"
data = json.loads(reader.json())
manifest = data["manifests"][data["active_manifest"]]
issuer = manifest["signature_info"]["issuer"]
codes = ",".join(s["code"] for s in data.get("validation_status", [])) or "-"
return f"{data['validation_state']} issuer={issuer} status={codes}"
def main() -> None:
for name in sys.argv[1:]:
path = Path(name)
raw = path.read_bytes()
with Image.open(path) as image:
width, height = image.size
xmp = "yes" if AI_MARK in raw else "no"
print(f"{path.name}: {width}x{height} {len(raw)}bytes")
print(f" c2pa: {c2pa_state(path)}")
print(f" xmp_ai_source: {xmp}")
main()
まず変換前の PNG を検査します。
python inspect_provenance.py cover-original.png
cover-original.png: 1376x768 1245809bytes
c2pa: Valid issuer=Google LLC status=signingCredential.untrusted
xmp_ai_source: yes
C2PA の署名者は Google LLC で、XMP の印もあります。
ValidとsigningCredential.untrustedが同居しているのは、矛盾ではありません。ハッシュと署名の検証は通っています。ただ、このスクリプトでは信頼リストを設定していないので、証明書は未信頼として報告されます。ここで見たいのは署名の有無と改ざん検知なので、信頼リストは足しませんでした。
5通りで変換して検査する
変換は 3 つのツールで 5 通りに分けました。画質は全部 88 にそろえます。
Pillow 用のconvert_pillow.pyです。--keep-xmpを付けると、元の XMP をそのまま渡します。
import sys
from PIL import Image
keep_xmp = len(sys.argv) > 1 and sys.argv[1] == "--keep-xmp"
image = Image.open("cover-original.png")
options = {"format": "WEBP", "quality": 88, "method": 6}
if keep_xmp:
options["xmp"] = image.info["XML:com.adobe.xmp"].encode("utf-8")
out = "cover-pillow-xmp.webp"
else:
out = "cover-pillow.webp"
image.save(out, **options)
print(f"saved {out}")
sharp 用のconvert_sharp.mjsです。--keep-metadataを付けたときだけwithMetadata()を呼びます。
import sharp from "sharp";
const keep = process.argv[2] === "--keep-metadata";
let pipeline = sharp("cover-original.png");
if (keep) {
pipeline = pipeline.withMetadata();
}
const out = keep ? "cover-sharp-meta.webp" : "cover-sharp.webp";
await pipeline.webp({ quality: 88 }).toFile(out);
console.log(`saved ${out} (sharp ${sharp.versions.sharp})`);
最初に、何も指定しない 3 通りを試しました。Pillow、ImageMagick、sharp の順です。
python convert_pillow.py
magick cover-original.png -quality 88 cover-magick.webp
node convert_sharp.mjs
python inspect_provenance.py cover-pillow.webp cover-magick.webp cover-sharp.webp
saved cover-pillow.webp
saved cover-sharp.webp (sharp 0.33.5)
cover-pillow.webp: 1376x768 70670bytes
c2pa: none (_C2paManifestNotFound)
xmp_ai_source: no
cover-magick.webp: 1376x768 73650bytes
c2pa: none (_C2paManifestNotFound)
xmp_ai_source: yes
cover-sharp.webp: 1376x768 72886bytes
c2pa: none (_C2paManifestNotFound)
xmp_ai_source: no
magickは成功時に何も表示しないので、出力は空です。
結果は次のとおりです。
| 方法 | C2PA署名 | XMPのAI生成表示 |
|---|---|---|
| Pillow、指定なし | 消えました | 消えました |
| ImageMagick | 消えました | 残りました |
| sharp、指定なし | 消えました | 消えました |
3 通りとも C2PA の署名は消えました。理由は構造にあります。C2PA は画像の中身のハッシュを記録するので、WebP に書き直した時点で、元の署名は新しい画像と結びつかなくなります。署名を引き継げる変換ツールは、今回の 3 つにはありませんでした。
XMP は扱いが分かれました。ImageMagick は何も指定しなくても XMP を引き継ぎましたが、Pillow と sharp は指定しないと落とします。
XMPを引き継ぐ指定を足す
落ちた 2 つに、メタデータを引き継ぐ指定を足して再実行します。
python convert_pillow.py --keep-xmp
node convert_sharp.mjs --keep-metadata
python inspect_provenance.py cover-pillow-xmp.webp cover-sharp-meta.webp
saved cover-pillow-xmp.webp
saved cover-sharp-meta.webp (sharp 0.33.5)
cover-pillow-xmp.webp: 1376x768 71434bytes
c2pa: none (_C2paManifestNotFound)
xmp_ai_source: yes
cover-sharp-meta.webp: 1376x768 74332bytes
c2pa: none (_C2paManifestNotFound)
xmp_ai_source: yes
Pillow はsave()のxmp引数、sharp はwithMetadata()で、XMP の AI 生成表示が残りました。ただし、C2PA の署名はどちらでも戻りません。
整理すると、WebP 変換後に残せるのは XMP の印までです。署名つきの証明は、PNG の原本に残ります。
原本を改ざんすると署名が壊れる
署名が何の役に立つのかも確認しました。原本 PNG の末尾へ 1 バイト足したコピーを作り、同じスクリプトで検査します。
cp cover-original.png cover-tampered.png
printf 'x' >> cover-tampered.png
python inspect_provenance.py cover-tampered.png
cover-tampered.png: 1376x768 1245810bytes
c2pa: Invalid issuer=Google LLC status=signingCredential.untrusted,assertion.dataHash.mismatch
xmp_ai_source: yes
状態がValidからInvalidに変わり、assertion.dataHash.mismatchが出ました。画像の見た目は何も変わっていません。それでも、1 バイトの追加が検知されています。
一方で、XMP のxmp_ai_source: yesは変わりません。XMP は平文の XML で、署名がないためです。印の有無は分かりますが、中身が改ざんされていないことまでは保証できません。
公開中の画像も調べた
自分のサイトで配信中の画像も、同じスクリプトで調べました。
curl -fsS -o live-epub.webp https://hibanas.net/images/posts/ai-epub-product-preflight.webp
curl -fsS -o live-json.webp https://hibanas.net/images/posts/ai-json-product-manifest-check.webp
python inspect_provenance.py live-epub.webp live-json.webp
live-epub.webp: 1600x900 47844bytes
c2pa: none (_C2paManifestNotFound)
xmp_ai_source: yes
live-json.webp: 1200x675 45844bytes
c2pa: none (_C2paManifestNotFound)
xmp_ai_source: no
2 枚とも C2PA の署名はありません。XMP の印は、1 枚には残り、もう 1 枚には残っていませんでした。変換方法をそろえていなかったので、画像ごとにばらついています。
私は、これを失敗として受け止めました。AI 生成の画像かどうかを、あとから機械的に見分けられないものがあるためです。次の商品カバーからは、変換時に XMP を引き継ぐ方法に固定し、変換後に必ずinspect_provenance.pyを通します。
販売前に残す確認
この検査で分かるのは、ファイルの中の印だけです。次の 3 点は、人が別に確認します。
- 原本の PNG を保管します。署名つきの証明は、原本にしか残りません
- 販売先の申告フォームで、表紙が AI-generated に当たるかを判断します
- 画像に文字や商標が写り込んでいないかを、目で見て確認します
原本の保管場所は、商品ごとのフォルダにcover-original.pngとして置くのが分かりやすいです。WebP は配信用の派生物と割り切り、変換のたびに原本から作り直します。
よくあるエラー
| 症状 | 原因 | 対応 |
|---|---|---|
_C2paManifestNotFoundと出ます | 画像にC2PAのマニフェストがありません。再エンコードで消えた可能性があります | 変換前の原本を検査し、署名の有無を確かめます |
ValidなのにsigningCredential.untrustedと出ます | 検証は通っていますが、信頼リストを設定していません | 署名の有無を見るだけなら、そのままで構いません |
assertion.dataHash.mismatchと出ます | 署名後に画像のバイト列が変わっています | 原本から取り直し、変換前後を比べます |
KeyError: 'XML:com.adobe.xmp'と出ます | 入力のPNGにXMPがありません | 生成APIが返したPNGをそのまま使っているか確かめます |
| Pillowで変換するとXMPが消えます | save()にxmpを渡していません | xmp=へ元のXMPをバイト列で渡します |
| sharpで変換するとXMPが消えます | デフォルトではメタデータを削除します | withMetadata()を付けます |
c2pa: none (KeyError)と出ます | 拡張子がMIME表にありません。署名がないのではなく、表に拡張子がないだけです | 検査する形式を表へ足します |
一次情報
| 資料 | 確認した内容 |
|---|---|
| C2PA Technical Specification 2.2 | ハードバインディングが、資産全体または一部を一意に識別する暗号ハッシュであること。マニフェストが署名つきで改ざんを検知できること |
| contentauth/c2pa-python | PythonからC2PAのマニフェストを読むReaderの提供元 |
| IPTC NewsCodes: trainedAlgorithmicMedia | 「Created using Generative AI」の定義 |
| Amazon KDP: Content Guidelines | AI生成の画像(表紙と挿絵を含む)を申告する必要があること。AI-generatedとAI-assistedの区別 |
| Pillow: Image file formats | WebP保存時にxmp引数でXMPを渡せること |
| sharp: Output options | デフォルトでは出力からメタデータが削除されること。withMetadata()で制御すること |


