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

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 点は、人が別に確認します。

  1. 原本の PNG を保管します。署名つきの証明は、原本にしか残りません
  2. 販売先の申告フォームで、表紙が AI-generated に当たるかを判断します
  3. 画像に文字や商標が写り込んでいないかを、目で見て確認します

原本の保管場所は、商品ごとのフォルダに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-pythonPythonからC2PAのマニフェストを読むReaderの提供元
IPTC NewsCodes: trainedAlgorithmicMedia「Created using Generative AI」の定義
Amazon KDP: Content GuidelinesAI生成の画像(表紙と挿絵を含む)を申告する必要があること。AI-generatedとAI-assistedの区別
Pillow: Image file formatsWebP保存時にxmp引数でXMPを渡せること
sharp: Output optionsデフォルトでは出力からメタデータが削除されること。withMetadata()で制御すること
この記事をシェア