誰かがあなたの製品に写真をアップロードし、「これはカメラで撮影したものです」と主張したとします。バックエンドでそれを証明、または反証できるでしょうか。目視確認は、生成AI画像が十分に自然になった時点で信頼できなくなりました。この記事では、C2PA Content Credentialsによる暗号的な来歴シグナルと、ホスト型AI画像分類器による統計的シグナルを組み合わせ、POST /verify エンドポイントとして実装する方法を説明します。サーバーはPython + FastAPI、来歴検証にはc2pa-python、分類器にはホスト型検出APIを使います。API契約は先にOpenAPIで設計し、Apidogでモックとテストを行います。
TL;DR
作るものは、画像アップロードを受け取り、次の2つのシグナルを評価するFastAPIサービスです。
-
c2pa-pythonでC2PA Content Credentialsマニフェストを抽出・検証する - ホスト型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
処理の流れは次の通りです。
- アップロードされた画像のMIMEタイプとサイズを検証する
- C2PAマニフェストをローカルで読み取り、署名・ハッシュを検証する
- 画像バイト列をホスト型AI画像分類器に送信する
- 2つの結果をルールベースで結合する
分類器がタイムアウトしても、C2PAシグナルだけで部分的な判定を返せます。逆に、C2PAマニフェストがなくても分類器で判定できます。
必要なパッケージをインストールします。
pip install fastapi "uvicorn[standard]" python-multipart httpx c2pa-python
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": "マニフェストが正常に読み取られました。",
}
重要なポイントは次の通りです。
-
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
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)}
この関数では、分類器の障害を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
レスポンス
レスポンスは、最終判定だけでなく、両方の生シグナルを含めます。
{
"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"
}
verdictは3値にします。
likely_authenticlikely_aiuncertain
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
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,
}
しきい値はユースケースに合わせて調整してください。報道、保険、マーケットプレイス、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(),
}
)
ローカルで起動します。
uvicorn main:app --reload
エンドポイントは次で利用できます。
http://127.0.0.1:8000/verify
c2pa-pythonのReaderはファイルパスを必要とするため、アップロードされた画像を一時ファイルに保存しています。処理後はfinallyで必ず削除します。
このように、コア機能を小さなAPIとして公開する設計は、APIがプロダクトそのものになる流れとも一致します。関連する考え方は、ソフトウェアのヘッドレス化でも説明しています。
Apidogでモックとテストを行う
バックエンドが完成する前でも、OpenAPI契約があればフロントエンドは作業を開始できます。
モックサーバーを生成する
ApidogにOpenAPIスキーマをインポートするか、ビジュアルデザイナーでPOST /verifyを定義します。
すると、Apidogはレスポンススキーマからモックサーバーを生成できます。フロントエンドは実バックエンドの代わりにモックURLを呼び出し、次のような状態を先に実装できます。
- 有効なカメラマニフェストを持つ
likely_authentic - AIツール名を含むマニフェストによる
likely_ai - 分類器が利用できない
uncertain 415 Unsupported Media Type413 Payload Too Large
実装後は、ベースURLをモックから実APIに切り替えるだけです。
エンドポイントテストを作る
バックエンドが動いたら、ApidogでPOST /verifyリクエストを作成します。
設定は次の通りです。
- Methodを
POSTにする - URLを
http://127.0.0.1:8000/verifyにする - Bodyで
form-dataを選ぶ -
imageフィールドを追加する - フィールドタイプをFileにする
- テスト画像を選択して送信する
次に、アサーションを追加します。
- ステータスコードが
200である -
verdictが存在する -
verdictがlikely_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
例:
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="有効な画像ファイルではありません。")
マニフェスト欠落
最も一般的なケースです。
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)