お断り: 本記事は C2PA Technical Specification v2.3(2026年4月時点)と c2pa-pythonAdobe Trustmark のソースコードおよび公開ドキュメントを筆者が読解して整理したものです。Soft Binding は仕様で算法(algorithm)レジストリが拡張され続けている領域であり、SDK の対応状況も変化します。実装や設計判断の根拠として用いる際は、必ずC2PA 公式仕様c2pa-org/softbinding-algorithm-list ・各ライブラリの最新ドキュメントをご確認ください。本記事に誤りや古くなった箇所を見つけられた場合は、記事末尾のフィードバック枠よりお知らせいただけると助かります。

はじめに

本記事は、シリーズ「C2PA 実装入門」 の応用編・ソフトバインディング編です。前編にあたるハードバインディング実装 では、c2pa-rs が c2pa.hash.data を自動付与してバイト単位の完全一致を保証する仕組みと、1 バイトの改変で assertion.dataHash.mismatch が飛ぶところまでを押さえました。

ハードバインディングは強力な反面、JPEG 再エンコードや SNS 投稿時のメタデータ除去であっさり壊れます。そこを埋めにいくのがソフトバインディングです。本記事では、C2PA 公認のソフトバインディングアルゴリズムレジストリに登録された Adobe Trustmark を使って、画像に不可視透かしを焼き付けつつ c2pa.soft_binding assertion として Manifest にも同じ識別子を残す流れを、Python のハンズオンで一気通貫に組んでいきます。

本記事で組み立てるパイプラインは、C2PA 仕様の別文書 C2PA Soft Binding API (Decoupled) で規定されている Manifest Recovery のフローを、com.adobe.trustmark.Q という具体的なアルゴリズムで実装したものになります。とくに Section 1.2.1.1 “Watermarking algorithms” で描かれている「Manifest が欠落した asset から不可視透かしを抽出し、soft binding algorithm list と resolution API を辿って active manifest を復元する」フローを参考設計とし、本記事では手を動かしていきます。

透かし埋め込みから検証までの流れ

本記事のハンズオンは次の順序で進めます。最初に Trustmark で画像に payload(短い識別子)を焼き付け、その透かし入り画像に対して c2pa-python で c2pa.soft_binding assertion を持つ Manifest を付与します。検証側は、Manifest に記録された value と画像から取り出した透かしの payload を突き合わせて、Manifest Recovery が成立するかを確かめる、という流れです。

flowchart TD A(["1. 元画像"]) B(["2. 透かし埋め込み(Trustmark)"]) C(["3. c2pa-python で署名"]) D(["4. 配信"]) E(["5. 検証 / Manifest Recovery"]) A --> B --> C -.-> D -.-> E

「透かしを埋め込んでから C2PA の署名をする」という順番が大事です。Trustmark は画像のピクセル値を書き換えるので、署名後に透かしを入れるとハードバインディング (c2pa.hash.data) があっさり壊れてしまいます。先に透かしを焼き付けてから、その状態の画像に対して Manifest を付与する。この順序で組むことで、ハードとソフトの両方が成立した出力が手に入ります。

ソフトバインディング assertion の構造

ソフトバインディングは、対応するアルゴリズム(alg)と、コンテンツから抽出した識別子(指紋や透かし)を c2pa.soft_binding assertion として Manifest に積みます。assertion の構造は仕様書 Section 18.2 で定義されていて、おおよそ次のフィールドで構成されます。

フィールド役割
algソフトバインディングアルゴリズムの識別子(ICANN ドメイン形式の名前を仕様が推奨)
blocksアルゴリズムが算出した識別子のリスト。各エントリは scopevalue を持つ
alg_paramsアルゴリズム固有のパラメータ(任意)
urlアルゴリズムの公開仕様への URL(任意)
description補足説明(任意)

仕様書(Section 9.3.2 “Referenced List of Soft Binding Algorithms”)では、alg の選び方について次のように書かれています。

Soft bindings are generated by an algorithm named in the alg field of the soft binding assertion. The algorithm name should be among those algorithms listed in the soft binding algorithm list as supported by this specification.

登録済みアルゴリズムの一覧は c2pa-org/softbinding-algorithm-list に JSON で管理されています。本記事ではその中から、Adobe が公開している透かし方式 Adobe Trustmarkalg = com.adobe.trustmark.Q)を使います。OSS 実装が pip で入る、画像への不可視透かし埋め込みから抽出まで Python で完結する、というあたりがハンズオンにちょうどいいです。

Trustmark で透かしを埋め込む

第5回で使った uv で Python プロジェクトを立ち上げます。Trustmark は PyTorch に依存するので、インストールには少し時間がかかります。

cd /tmp
mkdir -p c2pa-soft-binding && cd c2pa-soft-binding
uv init .
# Trustmark は Python 3.10〜3.12 に対応。uv の requires-python を合わせる
sed -i '' 's/requires-python = ">=3.12"/requires-python = ">=3.10,<3.13"/' pyproject.toml
uv python pin 3.11
uv add trustmark c2pa-python

# サンプル画像と c2pa-rs のテスト用証明書をダウンロード
curl -sL -o input.jpg https://raw.githubusercontent.com/contentauth/c2pa-rs/main/sdk/tests/fixtures/earth_apollo17.jpg
curl -sL -o test_cert.pem https://raw.githubusercontent.com/contentauth/c2pa-rs/main/sdk/tests/fixtures/certs/es256.pub
curl -sL -o test_key.pem https://raw.githubusercontent.com/contentauth/c2pa-rs/main/sdk/tests/fixtures/certs/es256.pem

# 以降のステップで作成する Python スクリプトを先に用意しておく
touch embed_watermark.py sign_soft_binding.py recover_manifest.py

この時点でプロジェクトはこんな構成になっています(.venv__pycache__ は省略)。

c2pa-soft-binding/
├── embed_watermark.py
├── input.jpg
├── main.py
├── pyproject.toml
├── README.md
├── recover_manifest.py
├── sign_soft_binding.py
├── test_cert.pem
├── test_key.pem
└── uv.lock

main.pyREADME.mduv init が自動生成したもので、本記事では使いません。作成済みの embed_watermark.py sign_soft_binding.py recover_manifest.py に順番に中身を書いていきます。

透かしと payload の関係

透かしを埋め込むというのは、ここでは「画像の全ピクセルに人間の目では知覚できない程度の微小な変調を加えて、短いビット列(payload)を画像データそのものに織り込む」操作を指します。Trustmark は学習済みのニューラルネットワークがエンコーダ・デコーダの役割を担っていて、元画像と payload を渡すと見た目をほとんど変えない範囲でピクセル値を調整してくれて、逆に画像を渡すとそこから payload に相当するビット列を推定して返してくれる、という構成です。元画像とほぼ見分けのつかない絵のまま、ファイル内部に識別子が紛れ込んでいる、と捉えてもらうのが近そうです。仕組みの詳細は Adobe Trustmark の READMEICCV 2025 採録の論文 PDF を参照してください。

payload は画像に結びつけたい任意の短い識別子です。内容そのものに来歴情報を詰め込むのではなく、「この画像は TT0001 として登録されているもの」というラベルだけを物理的に焼き付ける役割で、C2PA soft binding の文脈では後述のとおり Manifest を引くためのキーとして使います。Trustmark Q バリアントには英数字 7 文字くらいしか入らないので、本記事では TT0001 という最小限のダミー ID を使っています(TT は TechThanks の TT です)。C2PA のソフトバインディングレジストリには softBindingResolutionApis フィールドを持つアルゴリズムが登録されていて、画像から取り出した payload を解決 API に渡し、それに対応する Manifest を引き当てる設計が想定されています(例: com.aiwatermark.pixelseal.1、登録エントリは c2pa-org/softbinding-algorithm-list を参照)。

透かしはハードバインディングのバイト単位ハッシュとは違って、JPEG 再エンコードやリサイズみたいな「見た目がほぼ同じままの変換」であれば生き残るよう設計されています。おかげで、Manifest ごとメタデータが剥がれた配信物からでも、画像から payload を抽出 → 外部の Manifest Repository に問い合わせ、という経路で来歴に辿り着けます。

実装

Trustmark の Q バリアントで、短い識別子 TT0001 を埋め込みます。

# embed_watermark.py
from PIL import Image
from trustmark import TrustMark

tm = TrustMark(verbose=False, model_type="Q")

payload = "TT0001"  # Q variant の payload は英数字 7文字程度が実用上の上限
cover = Image.open("input.jpg").convert("RGB")
encoded = tm.encode(cover, payload, MODE="text")
encoded.save("watermarked.png")
print(f"埋め込み完了: payload='{payload}' -> watermarked.png")

decoded, detected, version = tm.decode(Image.open("watermarked.png"), MODE="text")
print(f"抽出結果: detected={detected} version={version} payload='{decoded}'")

tm.decode()(payload, detected, version) の 3 要素を返します。detected は透かしの復号に成功したかを示す bool で、判定はこの bool 一発です(確信度のような連続値は出てきません)。初回実行時は Trustmark のモデルファイルのフェッチが走ります。

uv run python embed_watermark.py
# => 埋め込み完了: payload='TT0001' -> watermarked.png
# => 抽出結果: detected=True version=1 payload='TT0001'

detected=True で payload が一致していて、透かし自体はちゃんと成立しています。次はこの画像に C2PA Manifest を付与して、c2pa.soft_binding assertion として同じ payload を残していきます。

c2pa-python で soft_binding assertion を記録する

# sign_soft_binding.py
import base64
import json

import c2pa

with open("test_cert.pem", "rb") as f:
    cert = f.read()
with open("test_key.pem", "rb") as f:
    key = f.read()

signer_info = c2pa.C2paSignerInfo(
    "es256", cert, key, b"http://timestamp.digicert.com"
)
signer = c2pa.Signer.from_info(signer_info)

payload = "TT0001"
payload_b64 = base64.b64encode(payload.encode("utf-8")).decode("ascii")

manifest_json = json.dumps({
    "title": "soft_binding_demo.png",
    "claim_generator_info": [{
        "name": "TechThanksSoftBindingDemo",
        "version": "0.1.0",
    }],
    "assertions": [
        {
            "label": "c2pa.actions.v2",
            "data": {
                "actions": [{
                    "action": "c2pa.created",
                    "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/digitalCapture",
                }],
            },
            "created": True,
        },
        {
            "label": "c2pa.soft_binding",
            "data": {
                "alg": "com.adobe.trustmark.Q",
                "url": "https://github.com/adobe/trustmark/",
                "description": "Adobe Trustmark variant Q invisible watermark",
                "blocks": [
                    {
                        "scope": {"start": 0, "length": 0},
                        "value": payload_b64,
                    }
                ],
            },
        },
    ],
})

builder = c2pa.Builder(manifest_json)
builder.sign_file("watermarked.png", "output.png", signer)
print("署名完了: output.png")
rm -f output.png
uv run python sign_soft_binding.py
# => 署名完了: output.png

c2patool output.png で Manifest を覗くと、Trustmark で埋め込んだ payload が c2pa.soft_binding としてきっちり記録されているのが見えます。

c2patool output.png | jq '.manifests[].assertions[] | select(.label == "c2pa.soft_binding")'
{
  "label": "c2pa.soft_binding",
  "data": {
    "alg": "com.adobe.trustmark.Q",
    "blocks": [
      {
        "scope": {"length": 0, "start": 0},
        "value": "VFQwMDAx"
      }
    ],
    "description": "Adobe Trustmark variant Q invisible watermark",
    "url": "https://github.com/adobe/trustmark/"
  }
}

valueVFQwMDAx は Base64 で TT0001 を表します。

echo VFQwMDAx | base64 -d
# => TT0001

画像(透かし)と Manifest(soft_binding value)で同じ識別子が持てていることが分かります。

余談: payload はハードコードでよいか

本記事のサンプルでは埋め込みと署名の両方で payload = "TT0001" をハードコードしていますが、設計としては「watermarked.png を Trustmark で decode し、その結果を c2pa.soft_binding の value に入れる」方が筋がいいと思っています。ソフトバインディングの本質は「画像から取り出せる payload に対して Manifest が紐づく」関係なので、画像側を真の値とみなす順序のほうが定義に沿います。実用上も、Trustmark の容量を超えた payload を渡すと黙って末尾が切られる挙動があるため、署名前に decode 結果と一致しているか確認しておくと不整合事故を防げます。一方、埋め込みと署名を同一プロセスで一気通貫に処理し、payload が容量内であることが事前にわかっているパイプラインなら、ハードコード(あるいは変数で持ち回す)でも問題ありません。Trustmark の decode は決して軽い処理ではない(モデル推論が走る)ため、整合性が確保されている前提なら省ける場面では省いた方が現実的、というのも実装上の判断としてあり得ます。ワークロードに応じて選んでいただければと思います。

Manifest Recovery をシミュレートする

ソフトバインディングの真価は「Manifest が剥がれてハードバインディングも通らなくなった画像から、来歴を引き直せる」ところにあります。ここでは SNS にアップロードされて JPEG に再エンコードされたような状況を再現して、その壊れた配信物から Manifest を復元するまでを通しでやってみます。

流れはこんな感じです。

  1. 元の output.png から Manifest を取り出し、c2pa.soft_binding の payload をキーに「Manifest Repository」へ登録する
  2. output.png を JPEG に再エンコードして、Manifest(JUMBF)を意図的に剥がす(tampered.jpg を作る)
  3. c2patool tampered.jpg で Manifest が検出できないことを確認する
  4. 透かしは画像ピクセルに焼かれているので、Trustmark で tampered.jpg から payload を抽出する
  5. 抽出した payload で Repository を引いて、元の Manifest を復元する

Manifest Repository は本番では Web API やキー値ストアになりますが、ここでは Python のグローバル dict で十分です。

# recover_manifest.py
import base64
import json
import subprocess

from PIL import Image
from trustmark import TrustMark


# --- 簡易 Manifest Repository(本番ではキー値ストアや Web API になる) ---
MANIFEST_REPOSITORY: dict[str, dict] = {}


def repository_register(payload: str, manifest: dict) -> None:
    MANIFEST_REPOSITORY[payload] = manifest


def repository_lookup(payload: str) -> dict | None:
    return MANIFEST_REPOSITORY.get(payload)


def read_manifest(path: str) -> dict | None:
    result = subprocess.run(
        ["c2patool", path], capture_output=True, text=True
    )
    if result.returncode != 0:
        return None
    return json.loads(result.stdout)


# --- 1) 元画像の Manifest を Repository に登録 ---
original = read_manifest("output.png")
assert original is not None, "output.png から Manifest を読めなかった"
active_id = original["active_manifest"]
sb = next(
    a for a in original["manifests"][active_id]["assertions"]
    if a["label"] == "c2pa.soft_binding"
)
payload_key = base64.b64decode(sb["data"]["blocks"][0]["value"]).decode("utf-8")
repository_register(payload_key, original["manifests"][active_id])
print(f"[1] Repository 登録: payload='{payload_key}'")

# --- 2) Manifest を剥がした画像を作る(JPEG 再エンコード) ---
Image.open("output.png").convert("RGB").save("tampered.jpg", "JPEG", quality=85)
print("[2] tampered.jpg を生成(JPEG 再エンコードで Manifest を除去)")

# --- 3) tampered.jpg に Manifest が残っていないことを確認 ---
tampered_manifest = read_manifest("tampered.jpg")
print(f"[3] tampered.jpg の Manifest: {'なし' if tampered_manifest is None else 'あり'}")

# --- 4) 画像から Trustmark で payload を抽出 ---
tm = TrustMark(verbose=False, model_type="Q")
extracted, detected, _ = tm.decode(Image.open("tampered.jpg"), MODE="text")
print(f"[4] tampered.jpg から payload を抽出: detected={detected} payload='{extracted}'")

# --- 5) Repository に問い合わせて Manifest を復元 ---
recovered = repository_lookup(extracted) if detected else None
if recovered is None:
    print("[5] NG: Repository から Manifest を復元できなかった")
else:
    print("[5] OK: Repository から Manifest を復元")
    print(f"    title           : {recovered['title']}")
    print(f"    claim_generator : {recovered['claim_generator_info'][0]['name']}")
    actions = next(
        a["data"]["actions"]
        for a in recovered["assertions"] if a["label"] == "c2pa.actions.v2"
    )
    print(f"    actions         : {[a['action'] for a in actions]}")
uv run python recover_manifest.py
# => [1] Repository 登録: payload='TT0001'
# => [2] tampered.jpg を生成(JPEG 再エンコードで Manifest を除去)
# => [3] tampered.jpg の Manifest: なし
# => [4] tampered.jpg から payload を抽出: detected=True payload='TT0001'
# => [5] OK: Repository から Manifest を復元
# =>     title           : soft_binding_demo.png
# =>     claim_generator : TechThanksSoftBindingDemo
# =>     actions         : ['c2pa.created']

JPEG 再エンコードによって Manifest(JUMBF)は完全に剥がれて、c2patool では Error: No claim found になります。それでも画像側のピクセルに焼かれた透かしは生きていて、Trustmark で payload を取り出して Repository を引けば、元 Manifest の titleclaim_generatoractions までそのまま復元できる、という流れがそのまま動きます。「画像が単独で出回っても、透かしから来歴に辿り着ける経路が一本残る」というソフトバインディングの狙いを、コードの実行結果として手元で確認できる構成です。

本番環境では、この MANIFEST_REPOSITORY がキー値ストアや Web API に置き換わります。

まとめ

C2PA の Hard Binding と Soft Binding は、対立概念ではなく補完関係にあります。Hard Binding は「Manifest と完全一致するコンテンツである」ことの証明、Soft Binding は「変換後でも Manifest を辿れる」ことの保険、という棲み分けです。c2pa-rs と c2patool は前者をほぼ自動で面倒見てくれますが、後者は運用要件に合わせて自分でアルゴリズムを選び、assertion として組み込む設計判断が要ります。

本記事では、Adobe Trustmark を使って画像に payload を焼き付け、c2pa-python から c2pa.soft_binding assertion として Manifest にも同じ識別子を残すところまでを、検証側の Manifest Recovery シミュレーションも含めて手元で確認しました。前編のハードバインディング実装 と合わせて読むと、両バインディングの守備範囲の違いがよりはっきり掴めるはずです。

TechThanks では、生成 AI 由来コンテンツやメディア配信における C2PA 導入の支援を行っています。Hard / Soft Binding の組み合わせ設計、Manifest Repository の構築、検証パイプラインの実装まで含めた相談がありましたら、お気軽にお問い合わせください。