DEV Community

Cover image for C2PAと分類器で構築するAI画像検出API
Akira
Akira

Posted on • Originally published at apidog.com

C2PAと分類器で構築するAI画像検出API

誰かがあなたの製品に写真をアップロードし、「これはカメラで撮影したものです」と主張したとします。バックエンドでそれを証明、または反証できるでしょうか。目視確認は、生成AI画像が十分に自然になった時点で信頼できなくなりました。この記事では、C2PA Content Credentialsによる暗号的な来歴シグナルと、ホスト型AI画像分類器による統計的シグナルを組み合わせ、POST /verify エンドポイントとして実装する方法を説明します。サーバーはPython + FastAPI、来歴検証にはc2pa-python、分類器にはホスト型検出APIを使います。API契約は先にOpenAPIで設計し、Apidogでモックとテストを行います。

今すぐApidogを試す

TL;DR

作るものは、画像アップロードを受け取り、次の2つのシグナルを評価するFastAPIサービスです。

  1. c2pa-pythonでC2PA Content Credentialsマニフェストを抽出・検証する
  2. ホスト型AI画像検出APIを呼び出し、AI生成らしさのスコアを取得する

最終的に、POST /verifyは次のようなJSONを返します。

  • verdict: likely_authentic / likely_ai / uncertain
  • confidence: 0〜1の信頼度
  • signals.provenance: C2PA検証結果
  • signals.classifier: AI分類器スコア
  • explanation: 人間が読める説明

なぜ2つのシグナルを使うのか

画像ファイルには、「これは人間が作った」「これはAIが作った」と断定できる単一のプロパティはありません。使えるのは複数の手がかりです。

1. C2PA来歴シグナル

C2PAは、画像などのメディアファイルに、改ざん検知可能で暗号的に署名されたメタデータを付与するためのオープン標準です。このメタデータはマニフェストと呼ばれ、ユーザー向けにはContent Credentialsとして知られています。

カメラ、編集ツール、画像生成ツールなどがC2PAに対応していれば、画像の作成・編集履歴を署名付きで記録できます。マニフェストを検証できれば、「どのツールがこの画像を生成・編集したか」について強い根拠を得られます。

ただし、C2PAはオプトインです。スクリーンショット、再エンコード、メッセージングアプリ経由の共有、アップロード時のメタデータ削除などでマニフェストは簡単に失われます。

つまり、マニフェストがないことは偽物の証拠ではありません

2. AI画像分類器シグナル

AI画像分類器は、画像ピクセルからAI生成らしさを推定します。メタデータがなくても動作するため、C2PAがない画像にも使えます。

ただし、分類器は確率的です。0.92というスコアは「AI生成の可能性が高い」という意味であり、「AI生成であることが証明された」という意味ではありません。圧縮、編集、未知の生成モデル、トレーニング分布外の画像では誤判定も起こります。

そのため、来歴と分類器を組み合わせます。

  • 来歴は強いが、存在しないことが多い
  • 分類器は広く使えるが、確実ではない
  • 両方を使うと、より実用的で説明可能な判定ができる

単一シグナルの限界については、AI画像検出が失敗する理由でも詳しく解説しています。

アーキテクチャ

サービスはシンプルです。

                ┌─────────────────────────────┐
   image  ──▶   │   FastAPI  POST /verify      │
                │                              │
                │   1. validate upload         │
                │   2. C2PA manifest check     │
                │   3. classifier API call     │
                │   4. combine into verdict    │
                └─────────────────────────────┘
                              │
                              ▼
                   JSON verdict + confidence
Enter fullscreen mode Exit fullscreen mode

処理の流れは次の通りです。

  1. アップロードされた画像のMIMEタイプとサイズを検証する
  2. C2PAマニフェストをローカルで読み取り、署名・ハッシュを検証する
  3. 画像バイト列をホスト型AI画像分類器に送信する
  4. 2つの結果をルールベースで結合する

分類器がタイムアウトしても、C2PAシグナルだけで部分的な判定を返せます。逆に、C2PAマニフェストがなくても分類器で判定できます。

必要なパッケージをインストールします。

pip install fastapi "uvicorn[standard]" python-multipart httpx c2pa-python
Enter fullscreen mode Exit fullscreen mode

C2PAシグナルを読む

Content Authenticity Initiativeは、contentauth GitHub組織でC2PA関連ツールを公開しています。

主に使うものは次の2つです。

  • c2patool: マニフェストの表示・追加に使えるCLI
  • c2pa-python: Rust実装であるc2pa-rsのPythonバインディング

このサービスではc2pa-pythonを使います。

# provenance.py
import json
import c2pa


def read_provenance(image_path: str) -> dict:
    """
    画像からC2PAマニフェストを読み取り、検証結果を正規化して返す。
    """
    try:
        with c2pa.Reader(image_path) as reader:
            manifest_store = json.loads(reader.json())

    except c2pa.C2paError as err:
        if str(err).startswith("ManifestNotFound"):
            return {
                "has_manifest": False,
                "validation": "none",
                "detail": "この画像にはC2PAマニフェストが存在しません。",
            }

        return {
            "has_manifest": True,
            "validation": "error",
            "detail": f"マニフェストを解析できませんでした: {err}",
        }

    active_label = manifest_store.get("active_manifest")
    manifests = manifest_store.get("manifests", {})
    active = manifests.get(active_label, {})

    validation_status = manifest_store.get("validation_status", [])
    validation = "valid" if not validation_status else "invalid"

    claim_generator = active.get("claim_generator", "unknown")
    signature_issuer = active.get("signature_info", {}).get("issuer", "unknown")

    return {
        "has_manifest": True,
        "validation": validation,
        "claim_generator": claim_generator,
        "signature_issuer": signature_issuer,
        "validation_status": validation_status,
        "detail": "マニフェストが正常に読み取られました。",
    }
Enter fullscreen mode Exit fullscreen mode

重要なポイントは次の通りです。

  • Readerはコンテキストマネージャーとして使う
  • reader.json()はマニフェストストアをJSON文字列で返す
  • 詳細なレポートが必要ならreader.detailed_json()も使える
  • ManifestNotFoundは異常ではなく、通常の状態として扱う
  • validation_statusが空なら検証成功、要素があれば検証失敗

特に注意すべきなのは、無効なマニフェストを信頼しないことです。マニフェストが存在していても、署名やハッシュが検証できなければ、それは強い警告として扱います。

分類器シグナルを取得する

分類器にはホスト型AI画像検出APIを使います。この記事では、文書化されたHTTP APIと明確なレスポンス形式があるSightengineを例にします。

他のベンダーを使う場合も、基本構造は同じです。

  • 画像を送る
  • AI生成スコアを読む
  • 失敗時は例外ではなく「利用不可」として扱う

AI画像検出APIの比較については、最高のAI画像検出APIも参考になります。

Sightengineのエンドポイントは次の通りです。

https://api.sightengine.com/1.0/check.json
Enter fullscreen mode Exit fullscreen mode

models=genaiを指定し、レスポンスのtype.ai_generatedを読みます。

# classifier.py
import httpx

SIGHTENGINE_URL = "https://api.sightengine.com/1.0/check.json"


async def classify_image(
    image_bytes: bytes,
    filename: str,
    api_user: str,
    api_secret: str,
    timeout_seconds: float = 8.0,
) -> dict:
    """
    画像をホスト型検出器に送信し、AI生成スコアを正規化して返す。
    """
    data = {
        "models": "genai",
        "api_user": api_user,
        "api_secret": api_secret,
    }
    files = {"media": (filename, image_bytes)}

    try:
        async with httpx.AsyncClient(timeout=timeout_seconds) as client:
            response = await client.post(SIGHTENGINE_URL, data=data, files=files)
            response.raise_for_status()
            payload = response.json()

    except httpx.TimeoutException:
        return {"available": False, "reason": "classifier_timeout"}

    except httpx.HTTPStatusError as err:
        return {
            "available": False,
            "reason": f"classifier_http_{err.response.status_code}",
        }

    except httpx.HTTPError as err:
        return {"available": False, "reason": f"classifier_error: {err}"}

    if payload.get("status") != "success":
        return {
            "available": False,
            "reason": payload.get("error", {}).get("message", "unknown_error"),
        }

    ai_score = payload.get("type", {}).get("ai_generated")
    if ai_score is None:
        return {"available": False, "reason": "missing_score_in_response"}

    return {"available": True, "ai_score": float(ai_score)}
Enter fullscreen mode Exit fullscreen mode

この関数では、分類器の障害をHTTP 500にしません。分類器が落ちている、遅い、レスポンス形式が変わった、という状況でも、呼び出し元には「分類器シグナルは利用不可」として返します。

画像がAI生成かどうかを確認する一般的な考え方は、画像がAI生成であるかを確認する方法でも説明しています。

/verify API契約を先に設計する

実装前に、POST /verifyの契約をOpenAPIで定義します。ここでApidogを使うと、次の作業を同じ場所で進められます。

  • APIスキーマの設計
  • モックサーバーの生成
  • フロントエンドとの並行開発
  • 実装後のエンドポイントテスト

リクエスト

multipart/form-dataで、imageというファイルフィールドを1つ受け取ります。

requestBody:
  required: true
  content:
    multipart/form-data:
      schema:
        type: object
        required: [image]
        properties:
          image:
            type: string
            format: binary
Enter fullscreen mode Exit fullscreen mode

レスポンス

レスポンスは、最終判定だけでなく、両方の生シグナルを含めます。

{
  "verdict": "likely_ai",
  "confidence": 0.86,
  "signals": {
    "provenance": {
      "has_manifest": true,
      "validation": "valid",
      "claim_generator": "SomeImageTool/2.1",
      "signature_issuer": "Some Issuing CA"
    },
    "classifier": {
      "available": true,
      "ai_score": 0.91
    }
  },
  "explanation": "A valid C2PA manifest names an AI image tool, and the classifier scored the image as likely AI-generated.",
  "checked_at": "2026-05-21T09:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

verdictは3値にします。

  • likely_authentic
  • likely_ai
  • uncertain

2値にしない理由は、正直なAPIにするためです。シグナルが競合した場合や、どちらも弱い場合は、uncertainが正しい応答です。

OpenAPIコンポーネントは次のように定義できます。

components:
  schemas:
    VerifyResponse:
      type: object
      required: [verdict, confidence, signals, checked_at]
      properties:
        verdict:
          type: string
          enum: [likely_authentic, likely_ai, uncertain]
        confidence:
          type: number
          format: float
          minimum: 0
          maximum: 1
        signals:
          type: object
          properties:
            provenance:
              type: object
              properties:
                has_manifest:
                  type: boolean
                validation:
                  type: string
                  enum: [valid, invalid, error, none]
                claim_generator:
                  type: string
                signature_issuer:
                  type: string
            classifier:
              type: object
              properties:
                available:
                  type: boolean
                ai_score:
                  type: number
                  format: float
        explanation:
          type: string
        checked_at:
          type: string
          format: date-time
Enter fullscreen mode Exit fullscreen mode

Apidogでは、このスキーマをビジュアルデザイナーで作成することも、既存のOpenAPIファイルをインポートすることもできます。仕様先行のワークフローについては、仕様先行モードのウォークスルーも参考になります。

判定ロジックを実装する

次に、C2PAシグナルと分類器シグナルを1つの判定にまとめます。

ここでは保守的なルールを使います。

  • 有効なC2PAマニフェストは強いシグナルとして扱う
  • 無効なマニフェストは警告として扱う
  • マニフェストがない場合は分類器にフォールバックする
  • シグナルが競合する場合はuncertainにする
# verdict.py

def combine_signals(provenance: dict, classifier: dict) -> dict:
    """来歴と分類器のシグナルを1つの判定に結合する。"""
    has_manifest = provenance.get("has_manifest", False)
    validation = provenance.get("validation", "none")
    generator = (provenance.get("claim_generator") or "").lower()

    classifier_ok = classifier.get("available", False)
    ai_score = classifier.get("ai_score")

    ai_keywords = (
        "firefly",
        "dall-e",
        "dalle",
        "midjourney",
        "stable",
        "gpt",
        "gemini",
        "imagen",
        "generat",
    )
    generator_looks_ai = any(k in generator for k in ai_keywords)

    if has_manifest and validation == "valid" and generator_looks_ai:
        return _verdict(
            "likely_ai",
            0.95,
            "有効なC2PAマニフェストがAI画像ツールを示しています。",
        )

    if has_manifest and validation == "valid" and not generator_looks_ai:
        if classifier_ok and ai_score is not None and ai_score > 0.85:
            return _verdict(
                "uncertain",
                0.55,
                "マニフェストは本物に見えますが、分類器スコアと競合しています。",
            )

        return _verdict(
            "likely_authentic",
            0.9,
            "非AIツールからの有効なC2PAマニフェストが存在します。",
        )

    if has_manifest and validation in ("invalid", "error"):
        return _verdict(
            "uncertain",
            0.6,
            "画像には検証に失敗したC2PAマニフェストが含まれています。",
        )

    if classifier_ok and ai_score is not None:
        if ai_score >= 0.7:
            return _verdict(
                "likely_ai",
                round(ai_score, 2),
                "来歴データはありません。分類器はAI生成の可能性が高いと評価しました。",
            )

        if ai_score <= 0.3:
            return _verdict(
                "likely_authentic",
                round(1 - ai_score, 2),
                "来歴データはありません。分類器は実写の可能性が高いと評価しました。",
            )

        return _verdict(
            "uncertain",
            0.5,
            "来歴データがなく、分類器スコアも決定的ではありません。",
        )

    return _verdict(
        "uncertain",
        0.0,
        "来歴データがなく、分類器も利用できませんでした。",
    )


def _verdict(verdict: str, confidence: float, explanation: str) -> dict:
    return {
        "verdict": verdict,
        "confidence": confidence,
        "explanation": explanation,
    }
Enter fullscreen mode Exit fullscreen mode

しきい値はユースケースに合わせて調整してください。報道、保険、マーケットプレイス、SNSでは許容リスクが異なります。

FastAPIアプリケーションを作る

最後に、アップロード検証、C2PA読み取り、分類器呼び出し、判定の結合を1つのエンドポイントにまとめます。

# main.py
import os
import tempfile
from datetime import datetime, timezone

from fastapi import FastAPI, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse

from provenance import read_provenance
from classifier import classify_image
from verdict import combine_signals

app = FastAPI(title="AI Image Detector API", version="1.0.0")

ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp"}
MAX_BYTES = 12 * 1024 * 1024

SIGHTENGINE_USER = os.environ.get("SIGHTENGINE_API_USER", "")
SIGHTENGINE_SECRET = os.environ.get("SIGHTENGINE_API_SECRET", "")


@app.post("/verify")
async def verify(image: UploadFile = File(...)):
    if image.content_type not in ALLOWED_TYPES:
        raise HTTPException(
            status_code=415,
            detail=(
                f"サポートされていないタイプ {image.content_type} です。"
                "JPEG、PNG、またはWebPを送信してください。"
            ),
        )

    image_bytes = await image.read()

    if len(image_bytes) == 0:
        raise HTTPException(status_code=400, detail="ファイルが空です。")

    if len(image_bytes) > MAX_BYTES:
        raise HTTPException(
            status_code=413,
            detail="ファイルが12MBの制限を超えています。",
        )

    suffix = os.path.splitext(image.filename or "")[1] or ".img"

    with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
        tmp.write(image_bytes)
        tmp_path = tmp.name

    try:
        provenance = read_provenance(tmp_path)
    finally:
        os.unlink(tmp_path)

    if SIGHTENGINE_USER and SIGHTENGINE_SECRET:
        classifier = await classify_image(
            image_bytes=image_bytes,
            filename=image.filename or "upload",
            api_user=SIGHTENGINE_USER,
            api_secret=SIGHTENGINE_SECRET,
        )
    else:
        classifier = {
            "available": False,
            "reason": "classifier_not_configured",
        }

    result = combine_signals(provenance, classifier)

    return JSONResponse(
        {
            "verdict": result["verdict"],
            "confidence": result["confidence"],
            "signals": {
                "provenance": {
                    k: provenance.get(k)
                    for k in (
                        "has_manifest",
                        "validation",
                        "claim_generator",
                        "signature_issuer",
                    )
                },
                "classifier": {
                    "available": classifier.get("available", False),
                    "ai_score": classifier.get("ai_score"),
                },
            },
            "explanation": result["explanation"],
            "checked_at": datetime.now(timezone.utc).isoformat(),
        }
    )
Enter fullscreen mode Exit fullscreen mode

ローカルで起動します。

uvicorn main:app --reload
Enter fullscreen mode Exit fullscreen mode

エンドポイントは次で利用できます。

http://127.0.0.1:8000/verify
Enter fullscreen mode Exit fullscreen mode

c2pa-pythonのReaderはファイルパスを必要とするため、アップロードされた画像を一時ファイルに保存しています。処理後はfinallyで必ず削除します。

このように、コア機能を小さなAPIとして公開する設計は、APIがプロダクトそのものになる流れとも一致します。関連する考え方は、ソフトウェアのヘッドレス化でも説明しています。

Apidogでモックとテストを行う

バックエンドが完成する前でも、OpenAPI契約があればフロントエンドは作業を開始できます。

モックサーバーを生成する

ApidogにOpenAPIスキーマをインポートするか、ビジュアルデザイナーでPOST /verifyを定義します。

すると、Apidogはレスポンススキーマからモックサーバーを生成できます。フロントエンドは実バックエンドの代わりにモックURLを呼び出し、次のような状態を先に実装できます。

  • 有効なカメラマニフェストを持つlikely_authentic
  • AIツール名を含むマニフェストによるlikely_ai
  • 分類器が利用できないuncertain
  • 415 Unsupported Media Type
  • 413 Payload Too Large

実装後は、ベースURLをモックから実APIに切り替えるだけです。

エンドポイントテストを作る

バックエンドが動いたら、ApidogでPOST /verifyリクエストを作成します。

設定は次の通りです。

  1. MethodをPOSTにする
  2. URLをhttp://127.0.0.1:8000/verifyにする
  3. Bodyでform-dataを選ぶ
  4. imageフィールドを追加する
  5. フィールドタイプをFileにする
  6. テスト画像を選択して送信する

次に、アサーションを追加します。

  • ステータスコードが200である
  • verdictが存在する
  • verdictlikely_authentic / likely_ai / uncertainのいずれかである
  • confidenceが0〜1の数値である
  • signals.provenance.has_manifestがbooleanである

さらに、複数画像を使うテストシナリオを保存します。

  • Content Credentials付き画像
  • マニフェストなしの通常JPEG
  • サイズ超過ファイル
  • .jpgにリネームした非画像ファイル
  • 分類器APIキー未設定時のケース

保存したシナリオは、リグレッションテストやCIで再利用できます。

堅牢化とエッジケース

壊れた画像ファイル

MIMEタイプがimage/jpegでも、実体が壊れたファイルである可能性があります。より厳密にするなら、Pillowなどでデコード確認を追加し、失敗したら400で拒否します。

pip install pillow
Enter fullscreen mode Exit fullscreen mode

例:

from io import BytesIO
from PIL import Image, UnidentifiedImageError


def validate_image_bytes(image_bytes: bytes) -> None:
    try:
        with Image.open(BytesIO(image_bytes)) as img:
            img.verify()
    except UnidentifiedImageError:
        raise HTTPException(status_code=400, detail="有効な画像ファイルではありません。")
Enter fullscreen mode Exit fullscreen mode

マニフェスト欠落

最も一般的なケースです。

C2PAマニフェストがないことは、エラーでも偽物の証拠でもありません。ManifestNotFoundは正常系として扱い、分類器にフォールバックします。

分類器のタイムアウト

分類器は外部依存です。必ずタイムアウトを設定し、失敗時はavailable: Falseを返します。

エンドポイント全体を失敗させるのではなく、判定品質を下げて応答します。

無効なマニフェスト

マニフェストがあっても、署名やハッシュが検証できない場合があります。

validation_statusを必ず確認してください。

  • 空配列: 検証成功
  • 要素あり: 検証失敗

検証に失敗したマニフェストは、証拠ではなく警告として扱います。

大容量ファイルと悪用

例では12MB制限を使っています。実運用ではさらに次を検討してください。

  • リバースプロキシでのアップロードサイズ制限
  • レートリミット
  • 認証
  • 分類器APIコストの監視
  • 同一画像ハッシュのキャッシュ

プライバシー

ユーザー画像を扱うため、次を守ります。

  • 画像バイト列をログに出さない
  • 一時ファイルは即削除する
  • 必要以上に保存しない
  • サードパーティ分類器へ送信することをプライバシーポリシーに明記する

各シグナルが捉えるもの

シナリオ C2PA来歴シグナル 分類器シグナル
Content Credentialsを書き込むツールからのAI画像 検出:マニフェストが生成元を示す 通常検出:アーティファクトを読む
メタデータが削除されたAI画像 見逃す:マニフェストなし 検出可能:ピクセルベースで動作
Content Credentials対応カメラからの実写写真 確認:有効な非AI生成元 圧縮や編集で誤検知の可能性
メタデータなしの実写写真 シグナルなし 確率的な推定のみ
偽造または改ざんされたマニフェスト validation_statusで検出 ピクセル次第
未知の新しい生成モデル ツールがマニフェストを書けば検出 見逃す可能性あり
実写ベースのAIレタッチ マニフェストがあれば編集履歴を記録 中間スコアになりやすい

来歴は正確ですが、存在しないことがあります。分類器は広く使えますが、曖昧です。だからこそ、両方を返し、必要に応じてuncertainを返す設計にします。

実世界のユースケース

このパターンは、次のような場面で使えます。

  • ユーザー生成コンテンツプラットフォーム

    アップロード画像を/verifyに通し、AI生成らしい画像や無効なマニフェストをレビュー対象にする。

  • 報道機関とファクトチェック

    バイラル画像について、暗号的来歴と分類器スコアを1回のAPI呼び出しで取得する。

  • 保険や請求受付

    提出写真が生成画像らしい場合や、マニフェスト検証に失敗した場合にフラグを立てる。

  • 内部アセットパイプライン

    AI生成画像をストックライブラリから除外、または明示的にラベル付けする。

  • 来歴対応CMS

    アップロード時にContent Credentialsを読み取り、検証済みバッジや警告を表示する。

まとめ

AI生成画像の検出は、完璧な単一テストを探す問題ではありません。独立したシグナルを組み合わせ、信頼度と不確実性を明示する問題です。

この実装では、次の構成を使いました。

  • C2PA Content Credentialsで暗号的な来歴を確認する
  • ホスト型AI画像分類器でピクセルベースの推定を行う
  • FastAPIでPOST /verifyとして公開する
  • likely_authentic / likely_ai / uncertainの3値判定を返す
  • OpenAPI契約を先に作り、Apidogでモックとテストを行う

重要なのは、uncertainを失敗として扱わないことです。検出が不確実なときに不確実だと返すことは、信頼できる検証APIに必要な機能です。

Top comments (0)