結論: 「AIが作ったか」ではなく「何が記録されているか」を見る

2026年8月、AI生成物への「印」を巡って各社が相次いで動きました(詳細は本日のニュースダイジェストにまとめています)。実務側の疑問はシンプルです。手元の画像に、その印は入っているのか。

この記事では、画像ファイルに埋め込まれた来歴情報(C2PA / Content Credentials)を調べるPythonスクリプトを作ります。標準ライブラリのみ・外部サービスへの送信なし・ファイルは読み取りのみです。

そして作ったスクリプトを、自社サイトの画像39枚に対して実際に実行しました。結果は検出1枚。その1枚から何が読み出せたかを、実出力のまま掲載します。

画像の来歴情報を調べる手順の図解

画像の来歴情報(C2PA)はどこに入っていて、何が読めるか

2026年8月15日 Python 3.11.1 / 標準ライブラリのみで実行検証

中身は JUMBF + CBOR。標準ライブラリだけで読める

この方法の限界 — ここを誤ると危険な使い方になる

格納場所は形式ごとに違う 見つかったら、こう読める(自社画像での実出力)
生成したソフト gpt-image 2.0
PNG caBX チャンク
素材の種別 trainedAlgorithmicMedia
JPEG APP11 セグメント
記録された操作 created / converted / watermarked
印が無い = AI未使用 ではない 印が有る = AIが著者 でもない
対応前ツール・再圧縮・形式変換で消える 校正・書き出しに使っただけでも記録は残る

動作環境(2026年8月15日 実行検証)

項目 値
Python 3.11.1
OS Windows 11
外部パッケージ なし(argparse / os / struct / sys / re のみ)
※テスト画像の作成にのみ Pillow 12.2.0 を使用
検証日 2026年8月15日
検証結果 passed(来歴情報を埋め込んだテスト画像で検出、埋め込んでいない画像で非検出。実ファイルで項目の読み出しに成功)

前提: 来歴情報はどこに入っているか

C2PA(Coalition for Content Provenance and Authenticity)の来歴情報は、JUMBFという入れ物にまとめられ、画像形式ごとに別の場所へ格納されます。

  • PNG — caBX という名前のチャンク
  • JPEG — APP11(マーカー 0xFFEB)セグメント

つまり、この2箇所を見に行けば「来歴情報が入っているか」は標準ライブラリだけで判定できます。中身はCBORという形式で書かれていますが、生成ソフト名や日時といった主要な項目は、キーの直後を読むだけで取り出せます。

手順1: スクリプトを用意する

check_provenance.py として保存します。

# -*- coding: utf-8 -*-
"""画像ファイルにAI生成の来歴情報(C2PA / Content Credentials)が入っているかを調べる。

usage: python check_provenance.py <ファイルまたはフォルダ> [...]
外部パッケージ不要(標準ライブラリのみ)。ファイルは読み取りのみで書き換えない。
"""
import argparse
import os
import struct
import sys

# C2PA のマニフェストは JUMBF (JPEG universal metadata box format) に格納される。
# 格納先はコンテナごとに違う: JPEG は APP11 セグメント、PNG は caBX チャンク。
C2PA_MARKERS = [b"c2pa", b"jumb", b"urn:uuid:", b"c2pa.assertions"]
XMP_MARKERS = [b"http://ns.adobe.com/xap/1.0/", b"<x:xmpmeta"]
# 生成AIツールが XMP / EXIF に残しがちな署名文字列(あくまで補助的な手がかり)
AI_HINTS = [b"SynthID", b"Content Credentials", b"contentcredentials",
            b"Made with Google AI", b"openai", b"DALL", b"Midjourney",
            b"Stable Diffusion", b"Adobe Firefly", b"Claude"]


def scan_png(data):
    """PNG のチャンクを走査し、来歴に関係するチャンク名を集める。"""
    found = []
    if not data.startswith(b"\x89PNG\r\n\x1a\n"):
        return None
    pos = 8
    while pos + 8 <= len(data):
        length = struct.unpack(">I", data[pos:pos + 4])[0]
        ctype = data[pos + 4:pos + 8]
        if ctype == b"caBX":                      # C2PA の格納先チャンク
            found.append(("C2PA", "caBXチャンク", length, data[pos + 8:pos + 8 + length]))
        elif ctype in (b"iTXt", b"tEXt", b"zTXt"):
            body = data[pos + 8:pos + 8 + min(length, 400)]
            if any(m in body for m in XMP_MARKERS):
                found.append(("XMP", ctype.decode(), length, None))
            for hint in AI_HINTS:
                if hint.lower() in body.lower():
                    found.append(("HINT", hint.decode(errors="replace"), length, None))
        if ctype == b"IEND":
            break
        pos += 12 + length                        # length + type(4) + data + crc(4)
    return found


def scan_jpeg(data):
    """JPEG のマーカーセグメントを走査する。C2PA は APP11(0xFFEB)。"""
    found = []
    if not data.startswith(b"\xff\xd8"):
        return None
    pos = 2
    while pos + 4 <= len(data):
        if data[pos] != 0xFF:
            break
        marker = data[pos + 1]
        if marker == 0xDA:                        # SOS 以降は画素データ
            break
        seglen = struct.unpack(">H", data[pos + 2:pos + 4])[0]
        body = data[pos + 4:pos + 2 + seglen]
        if marker == 0xEB:                        # APP11
            if any(m in body for m in C2PA_MARKERS):
                found.append(("C2PA", "APP11/JUMBF", seglen, body))
        elif marker == 0xE1:                      # APP1 (EXIF / XMP)
            if any(m in body for m in XMP_MARKERS):
                found.append(("XMP", "APP1", seglen, None))
        for hint in AI_HINTS:
            if hint.lower() in body.lower():
                found.append(("HINT", hint.decode(errors="replace"), seglen, None))
        pos += 2 + seglen
    return found


ACTION_NAMES = [b"c2pa.created", b"c2pa.converted", b"c2pa.edited",
                b"c2pa.watermarked.unbound", b"c2pa.opened", b"c2pa.placed"]


def cbor_text_at(blob, pos):
    """blob[pos] を CBOR のテキスト文字列ヘッダとみなして中身を取り出す。

    CBORのテキスト文字列は 0x60|長さ(長さ23まで)、0x78+1バイト長 で始まる。
    ヘッダの長さ情報を使うので、隣の項目を巻き込まずに値だけを取れる。
    """
    if pos >= len(blob):
        return None
    head = blob[pos]
    if 0x60 <= head <= 0x77:
        length, start = head - 0x60, pos + 1
    elif head == 0x78 and pos + 1 < len(blob):
        length, start = blob[pos + 1], pos + 2
    else:
        return None
    return blob[start:start + length].decode("utf-8", errors="replace")


def value_after_key(blob, key):
    """CBORマップの key に続く値(テキスト文字列)を返す。"""
    i = blob.find(key)
    if i < 0:
        return None
    return cbor_text_at(blob, i + len(key))


def describe(blob):
    """C2PAマニフェストから読み取れた項目を (ラベル, 値) の一覧で返す。

    CBORを完全に解析せず、必要なキーの直後だけを読む簡易版。
    厳密な署名検証は行わないため、詳細はC2PA公式の検証ツールに任せる。
    """
    import re as _re
    out = []
    # softwareAgent は {"name": ..., "version": ...} のマップになっている
    i = blob.find(b"softwareAgent")
    if i >= 0:
        agent = value_after_key(blob[i:i + 80], b"dname")
        version = value_after_key(blob[i:i + 80], b"gversion")
        if agent:
            out.append(("生成したソフト", agent + (f" {version}" if version else "")))
    gen = value_after_key(blob, b"claim_generator_info\xa2dname") \
        or value_after_key(blob, b"tclaim_generator_info\xa2dname")
    if gen is None:
        j = blob.find(b"claim_generator_info")
        if j >= 0:
            gen = value_after_key(blob[j:j + 120], b"dname")
    if gen:
        out.append(("マニフェスト作成者", gen))
    m = _re.search(rb"digitalsourcetype/([a-zA-Z]+)", blob)
    if m:
        out.append(("素材の種別", m.group(1).decode()))
    m = _re.search(rb"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)", blob)
    if m:
        out.append(("記録された日時", m.group(1).decode()))
    acts = [a.decode() for a in ACTION_NAMES if a in blob]
    if acts:
        out.append(("記録された操作", " / ".join(acts)))
    return out


def scan(path):
    with open(path, "rb") as f:
        data = f.read()
    result = scan_png(data)
    kind = "PNG"
    if result is None:
        result = scan_jpeg(data)
        kind = "JPEG"
    if result is None:
        # 未対応コンテナは全文検索にフォールバック(誤検出しうるので参考値)
        kind = "OTHER"
        result = [("C2PA?", "全文一致(参考)", len(data), data)
                  for m in C2PA_MARKERS if m in data][:1]
    return kind, result


def collect(targets):
    files = []
    for t in targets:
        if os.path.isdir(t):
            for root, _, names in os.walk(t):
                for n in sorted(names):
                    if n.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
                        files.append(os.path.join(root, n))
        else:
            files.append(t)
    return files


def main():
    # Windowsのコンソール既定文字コードだと日本語が化けるため明示する
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    ap = argparse.ArgumentParser()
    ap.add_argument("targets", nargs="+")
    args = ap.parse_args()

    files = collect(args.targets)
    if not files:
        print("対象ファイルがありません")
        return 1

    marked = 0
    for path in files:
        try:
            kind, found = scan(path)
        except Exception as e:
            print(f"[ERROR] {path}: {e}")
            continue
        c2pa = [f for f in found if f[0].startswith("C2PA")]
        xmp = [f for f in found if f[0] == "XMP"]
        hints = [f for f in found if f[0] == "HINT"]
        if c2pa:
            marked += 1
            label = "来歴情報あり"
        elif hints:
            label = "手がかりあり(来歴情報なし)"
        else:
            label = "検出なし"
        print(f"{label:<22} [{kind:<5}] {os.path.basename(path)}")
        for tag, detail, size, blob in c2pa + xmp + hints:
            print(f"    - {tag}: {detail} ({size} bytes)")
            if blob:
                for key, value in describe(blob):
                    print(f"        {key}: {value}")
    print(f"\n{len(files)}件中 {marked}件でC2PA来歴情報を検出しました。")
    print("注意: 検出は「AIが関与した可能性」を示すだけで、著者や来歴を確定しません。")
    return 0


if __name__ == "__main__":
    sys.exit(main())

手順2: 検出できることを先に確かめる

いきなり本番のファイルに使うと、「検出されなかった」のがファイルの性質なのかスクリプトの不具合なのか区別できません。先に、検出されるはずの画像で反応を確認します。

来歴情報の格納場所(PNGのcaBXチャンク、JPEGのAPP11セグメント)を模したテスト画像を作ります。これは検出器の動作確認専用の合成ファイルで、実際のAI生成物ではありません。

# -*- coding: utf-8 -*-
"""検証用のテスト画像を作る(plain=埋め込みなし / marked=格納場所を模したもの)。"""
import struct
import zlib

from PIL import Image

img = Image.new("RGB", (64, 64), (250, 250, 248))
img.save("plain_image.png")
img.save("plain_photo.jpg", quality=90)

with open("plain_image.png", "rb") as f:
    png = f.read()

jumbf = b"\x00\x00\x00\x20jumb\x00\x00\x00\x18jumdc2pa\x00\x11\x00\x10c2pa.assertions"
ctype = b"caBX"
chunk = struct.pack(">I", len(jumbf)) + ctype + jumbf
chunk += struct.pack(">I", zlib.crc32(ctype + jumbf) & 0xFFFFFFFF)

iend = png.rfind(b"\x00\x00\x00\x00IEND")
with open("marked_image.png", "wb") as f:
    f.write(png[:iend] + chunk + png[iend:])

with open("plain_photo.jpg", "rb") as f:
    jpg = f.read()

payload = b"JP\x00\x01" + jumbf
seg = b"\xff\xeb" + struct.pack(">H", len(payload) + 2) + payload
with open("marked_photo.jpg", "wb") as f:
    f.write(jpg[:2] + seg + jpg[2:])

実行結果(実出力):

来歴情報あり                 [PNG  ] marked_image.png
    - C2PA: caBXチャンク (39 bytes)
来歴情報あり                 [JPEG ] marked_photo.jpg
    - C2PA: APP11/JUMBF (45 bytes)
検出なし                   [PNG  ] plain_image.png
検出なし                   [JPEG ] plain_photo.jpg

4件中 2件でC2PA来歴情報を検出しました。
注意: 検出は「AIが関与した可能性」を示すだけで、著者や来歴を確定しません。

埋め込んだ2枚だけが検出され、埋め込んでいない2枚は検出されませんでした。検出器として機能していることが確認できたので、本番に進みます。

手順3: 自社サイトの画像を全部スキャンする

当メディアのサイトで使っている画像フォルダをまるごと指定しました。

python check_provenance.py C:\projects\aidia-media\assets\img

実行結果(検出された行のみ抜粋・実出力):

来歴情報あり                 [PNG  ] playground-launch.png
    - C2PA: caBXチャンク (29087 bytes)
        生成したソフト: gpt-image 2.0
        マニフェスト作成者: OpenAI Media Service API
        素材の種別: trainedAlgorithmicMedia
        記録された日時: 2026-08-12T00:00:00Z
        記録された操作: c2pa.created / c2pa.converted / c2pa.watermarked.unbound

39件中 1件でC2PA来歴情報を検出しました。
注意: 検出は「AIが関与した可能性」を示すだけで、著者や来歴を確定しません。

39枚中1枚。そしてその1枚に、29,087バイトの来歴情報がぶら下がっていました。

読み出せた内容は、当メディアの運営記録と一致します。この画像は2026年8月12日に画像生成AIで作成したバナー素材で、ファイル側の記録も同じ日付・同じ素材種別(trainedAlgorithmicMedia = 学習済みアルゴリズムによる生成物)を示しています。

注目すべきは記録された操作の3つめです。c2pa.watermarked.unbound — このファイルは、来歴情報に加えてウォーターマークが施されたことまで自己申告しているわけです。今まさに各社が進めている「AI生成物への印」の実物が、すでに自社のフォルダの中にありました。

残る38枚が検出なしだったのは、その大半が自前で作成したSVGから変換した図解だからです。ただし後述するとおり、WebP形式は本スクリプトの正式対応外である点も、この数字には含まれています。

この方法で分かること・分からないこと

実行して分かった限界を、そのまま書きます。ここを誤解すると危険な使い方に直行します。

分かること

  • ファイルに来歴情報が格納されているか(PNG / JPEG)
  • 格納されている場合、生成ソフト名・バージョン・素材の種別・記録された日時・記録された操作

分からないこと

  • 来歴情報が無い=AI未使用、ではない。対応前のツールで作られた、スクリーンショットを撮り直した、再圧縮や形式変換で情報が落ちた——いずれの場合も残りません
  • 来歴情報が有る=AIが著者、でもない。AIで下地を作って人が描き直した場合も、人の作品をAIツールで書き出しただけの場合も記録は付きます
  • 署名の正当性・改ざんの有無は検証していません。本スクリプトは格納場所を探して読むだけで、暗号的な検証は行いません
  • WebP・SVGは正式対応外です。WebPはOTHERとして全文検索のフォールバックにかかるだけで、正確な判定ではありません

この2つの「分からないこと」は、提供者側も同じことを言っています。Anthropicは公式ヘルプで、印の検出は「Claudeによって処理された可能性」を示すだけで来歴を確定しないと明記し、印が無くてもAI生成でないとは言えないと説明しています。Googleも、GeminiのSynthID検証は自社AIで作られたコンテンツしか認識できないと注記しています。

実務での使いどころ

以上を踏まえると、使ってよい方向と使ってはいけない方向がはっきりします。

向いている使い方 — 自社の制作物の棚卸し

自社が外部に出す画像に、意図しない来歴情報が残っていないかを確認する用途です。AI生成素材を使うこと自体は問題ありませんが、「どの画像がAI由来か」を自社で把握できていない状態は、後から説明を求められた時に困ります。今回の実行のように、フォルダ単位で1回流せば棚卸しが済みます。

向いていない使い方 — 他者の検査

取引先の納品物や社員の制作物を検査して、AI利用を問い詰める使い方です。無くてもAI生成でないとは言えず、有っても著者を確定できない以上、誤判定は両方向に起きます。

実務的な順序はこうです。先にルールを決め、後から機械で補助する。AI利用の可否と開示範囲を発注時の取り決めや社内規程で決めておけば、来歴情報はその合意を確認する材料になります。逆順にすると、機械の出力を根拠に人を疑うことになります。

まとめ

画像の来歴情報は、標準ライブラリだけで読めます。PNGならcaBXチャンク、JPEGならAPP11セグメント——場所さえ分かれば難しい処理ではありません。

そして実際に自社サイトを1回スキャンしてみると、思っていたより具体的な記録が出てきました。生成ソフト名、バージョン、日時、施された操作。29KB分の情報が、意識しないまま画像と一緒に配信されていたわけです。

まずは自社の画像フォルダに1回流してみてください。自社が何を配っているかを知るのが最初の一歩で、他者を検査するのはその後の話ではなく、そもそも別の話です。

AI生成物への「印」を巡る各社の動きの詳細は、2026年8月15日のニュースダイジェストで一次ソースにあたって整理しています。

VERIFIED
動作確認日: 2026.08.15 / 環境: Python 3.11.1 / Windows 11 / 標準ライブラリのみ(テスト画像の作成にのみPillow 12.2.0を使用) / ステータス: passed

よくある質問

来歴情報が検出されなければ、AIで作られていないと判断できますか?
できません。来歴情報は、対応したツールで生成・書き出しされた場合にのみ付きます。対応前のツールで作られた画像、スクリーンショットを撮り直した画像、再圧縮や形式変換で情報が落ちた画像には残りません。実際、本記事の検証でも自社サイトの画像39枚のうち38枚は検出なしでしたが、これは「38枚がAI未使用」という意味ではなく、大半が自前で作成したSVG由来のファイルだったという事情に加え、WebP形式が本スクリプトの正式対応外である点も含みます。
来歴情報があれば、AIが作ったと断定できますか?
断定はできません。C2PAの来歴情報は「このファイルにどの操作が記録されているか」を示すもので、著作者を確定するものではありません。AIで下地を作って人が大幅に描き直した場合も記録は残りますし、逆に人が作った画像をAIツールで書き出しただけでも記録が付くことがあります。Anthropicも公式ヘルプで、印の検出は「処理された可能性」を示すだけでコンテンツの完全な来歴を確定するものではないと明記しています。判断材料の1つとして扱ってください。
このスクリプトで署名の正当性まで検証できますか?
できません。本スクリプトが行うのは、来歴情報の格納場所(PNGのcaBXチャンク、JPEGのAPP11セグメント)を探して、中身から読み取れる項目を表示するところまでです。署名が正規の発行者によるものか、ファイルが改ざんされていないかの暗号的な検証は行いません。厳密な検証が必要な場合は、C2PA公式の検証ツールや、対応サービスのアップロード検証(GeminiアプリでSynthIDを確認するなど)を併用してください。
取引先から納品された画像の検査に使えますか?
検査の道具としては勧めません。理由は2つあり、来歴情報が無くてもAI生成でないとは言えないこと、有っても著者を確定できないことです。この2つの限界があるため、検出結果を根拠に取引先を疑う運用は誤判定の両方向のリスクを抱えます。実務的には、AI利用の可否と開示範囲を契約や発注時の取り決めで先に決めておき、本スクリプトは自社の制作物に意図しない来歴情報が残っていないかを確認する用途に使うのが安全です。
AI導入・活用支援

AI導入・実装のどこから着手するかを、30分で整理します

支援内容・進め方・料金の考え方を、30分のオンラインで整理します。フォームは3項目、送信は30秒です。

無料で相談する(30秒で送信)

先にサービス内容と料金を見る →

無料で相談してみる →