惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

V2EX - 技术
V2EX - 技术
A
Arctic Wolf
C
CERT Recently Published Vulnerability Notes
S
Schneier on Security
Security Latest
Security Latest
P
Privacy & Cybersecurity Law Blog
C
Cybersecurity and Infrastructure Security Agency CISA
C
Cisco Blogs
T
The Exploit Database - CXSecurity.com
G
GRAHAM CLULEY
N
News and Events Feed by Topic
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
CXSECURITY Database RSS Feed - CXSecurity.com
有赞技术团队
有赞技术团队
月光博客
月光博客
Cloudbric
Cloudbric
Hacker News - Newest:
Hacker News - Newest: "LLM"
L
LangChain Blog
博客园 - 【当耐特】
T
Tenable Blog
S
Securelist
The GitHub Blog
The GitHub Blog
Webroot Blog
Webroot Blog
Project Zero
Project Zero
A
About on SuperTechFans
MongoDB | Blog
MongoDB | Blog
罗磊的独立博客
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
V
Vulnerabilities – Threatpost
Engineering at Meta
Engineering at Meta
T
Threat Research - Cisco Blogs
Blog — PlanetScale
Blog — PlanetScale
Microsoft Azure Blog
Microsoft Azure Blog
Google Online Security Blog
Google Online Security Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
Y
Y Combinator Blog
S
SegmentFault 最新的问题
Martin Fowler
Martin Fowler
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
AI
AI
T
Tor Project blog
O
OpenAI News
Know Your Adversary
Know Your Adversary
H
Help Net Security
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Last Week in AI
Last Week in AI
L
LINUX DO - 最新话题
Latest news
Latest news
F
Full Disclosure
K
Kaspersky official blog

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
C2PA 및 분류기를 사용한 AI 이미지 감지기 API 구축
Rihpig · 2026-05-21 · via DEV Community

누군가 제품에 사진을 업로드하고 “카메라로 직접 촬영했다”고 주장합니다. 백엔드는 이를 검증할 수 있을까요? 이미지 생성기는 이미 사람 눈으로 구분하기 어려운 결과물을 만들고 있습니다. 따라서 “눈으로 봐서 진짜 같음”은 더 이상 검증 전략이 아닙니다. 대신 암호화된 출처 매니페스트(C2PA)와 머신러닝 분류기라는 두 가지 독립 신호를 결합하면, 어느 한쪽만 사용할 때보다 더 신뢰할 수 있는 판정을 만들 수 있습니다.

지금 Apidog 사용해 보기

이 글에서는 POST /verify 엔드포인트를 가진 FastAPI 서비스를 구현합니다. 이미지를 업로드하면 서비스는 C2PA 매니페스트를 읽고, 호스팅된 AI 이미지 탐지 API를 호출한 뒤, 신뢰도와 원시 신호를 포함한 JSON 판정을 반환합니다. 또한 구현 전에 OpenAPI 계약을 먼저 설계하고 Apidog에서 모의 서버와 테스트를 구성해 프론트엔드가 백엔드 완성 전에도 통합을 시작할 수 있게 합니다.

요약 (TL;DR)

구현할 서비스는 다음 흐름을 따릅니다.

  1. POST /verify에서 이미지 파일을 multipart/form-data로 받습니다.
  2. c2pa-python으로 C2PA 콘텐츠 자격 증명 매니페스트를 추출하고 검증합니다.
  3. Sightengine 같은 호스팅 AI 탐지 API를 호출해 AI 생성 가능성 점수를 받습니다.
  4. 두 신호를 결합해 다음 중 하나를 반환합니다.
    • likely_authentic
    • likely_ai
    • uncertain
  5. 응답에는 confidence, signals.provenance, signals.classifier, explanation을 포함합니다.
  6. OpenAPI 스키마를 먼저 작성하고 Apidog에서 모의 서버와 반복 가능한 테스트 시나리오를 만듭니다.

하나의 신호 대신 두 개의 신호를 사용하는 이유

이미지 파일에는 “인간이 만들었다” 또는 “AI가 만들었다”를 알려주는 단일 속성이 없습니다. 대신 서로 다른 성격의 단서가 있습니다.

1. C2PA 출처 신호

C2PA(Content Provenance and Authenticity)는 미디어 파일에 암호화 서명된 메타데이터를 첨부하는 오픈 표준입니다. 이 메타데이터 묶음을 매니페스트라고 하며, 사용자에게는 콘텐츠 자격 증명(Content Credentials)으로 알려져 있습니다.

카메라, 편집기, 이미지 생성기 같은 도구가 C2PA를 지원하면 다음 정보를 기록할 수 있습니다.

  • 어떤 도구가 이미지를 만들었는지
  • 어떤 편집이 있었는지
  • 누가 서명했는지
  • 파일 내용이 서명 이후 변경되었는지

매니페스트가 유효하면 강력한 출처 신호가 됩니다.

하지만 C2PA는 옵트인 방식입니다. 스크린샷, 메신저 재인코딩, 플랫폼 업로드 과정에서 메타데이터가 제거될 수 있습니다. 따라서 매니페스트가 없다는 사실은 이미지가 가짜라는 뜻도, 진짜라는 뜻도 아닙니다.

2. AI 탐지 분류기 신호

분류기는 이미지 픽셀을 분석해 AI 생성 가능성을 0~1 점수로 반환합니다. 메타데이터가 없어도 동작하지만 확률적입니다.

예를 들어 0.92는 “모델이 AI 생성 가능성이 높다고 본다”는 의미이지, “AI 생성이 증명되었다”는 의미가 아닙니다.

따라서 설계 목표는 다음과 같습니다.

C2PA가 암호화로 증명하는 것과 분류기가 통계적으로 추정하는 것을 분리해서 보여주고, 두 신호를 조합해 보수적인 판정을 반환한다.

단일 신호 접근 방식의 실패 모드는 AI 이미지 탐지가 실패하는 이유에서 더 자세히 다룹니다.

아키텍처 개요

서비스는 작게 유지합니다.

                ┌─────────────────────────────┐
   이미지  ──▶   │   FastAPI POST /verify       │
                │                              │
                │   1. 업로드 검증              │
                │   2. ┌──────────────────┐    │
                │      │ C2PA 매니페스트    │    │  출처 신호
                │      │ c2pa-python       │    │
                │      └──────────────────┘    │
                │   3. ┌──────────────────┐    │
                │      │ 분류기 API         │    │  통계 신호
                │      │ 호스팅 탐지기      │    │
                │      └──────────────────┘    │
                │   4. 판정 결합               │
                └─────────────────────────────┘
                              │
                              ▼
                   JSON 판정 + 신뢰도

Enter fullscreen mode Exit fullscreen mode

사용할 스택은 다음과 같습니다.

  • Python 3.10+
  • FastAPI
  • Uvicorn
  • python-multipart
  • httpx
  • c2pa-python

설치합니다.

pip install fastapi "uvicorn[standard]" python-multipart httpx c2pa-python

Enter fullscreen mode Exit fullscreen mode

C2PA 신호 구현

콘텐츠 진위 이니셔티브(Content Authenticity Initiative)는 C2PA 관련 오픈 소스 도구를 제공합니다.

주요 도구는 다음과 같습니다.

  • c2patool: 터미널에서 매니페스트를 확인하거나 추가하는 CLI 도구
  • c2pa-python: Rust 기반 c2pa-rs 라이브러리의 Python 바인딩

서비스에서는 c2pa-python을 사용합니다.

provenance.py

# provenance.py
import json
import c2pa


def read_provenance(image_path: str) -> dict:
    """
    이미지에서 C2PA 매니페스트를 읽고 검증한다.
    결과는 서비스 내부에서 사용하기 쉬운 dict로 정규화한다.
    """
    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": "No C2PA manifest present in this image.",
            }

        # C2PA 데이터가 있지만 파싱 또는 검증에 실패한 경우
        return {
            "has_manifest": True,
            "validation": "error",
            "detail": f"Could not parse manifest: {err}",
        }

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

    # validation_status는 검증 문제가 있을 때 채워진다.
    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": "Manifest read successfully.",
    }

Enter fullscreen mode Exit fullscreen mode

핵심 포인트는 다음과 같습니다.

  • Reader는 컨텍스트 매니저로 사용해 리소스를 정리합니다.
  • reader.json()은 전체 매니페스트 저장소를 JSON 문자열로 반환합니다.
  • 더 자세한 보고서가 필요하면 reader.detailed_json()을 사용할 수 있습니다.
  • ManifestNotFound는 실패가 아니라 “출처 신호 없음”이라는 데이터로 처리합니다.
  • validation_status가 비어 있으면 검증 통과, 값이 있으면 검증 실패로 봅니다.

매니페스트가 없을 때는 판정할 수 없습니다. 그래서 두 번째 신호가 필요합니다.

분류기 신호 구현

분류기는 이미지가 AI 생성일 가능성을 점수화합니다. 이 글에서는 Sightengine의 AI 탐지 API를 예시로 사용합니다. 다른 공급업체를 사용해도 패턴은 같습니다.

  • 이미지 바이트를 API에 업로드
  • AI 생성 점수 필드를 읽음
  • 실패하면 예외 대신 available: False를 반환

AI 이미지 탐지 API를 비교하고 있다면 최고의 AI 이미지 탐지 API를 참고할 수 있습니다.

Sightengine 엔드포인트는 다음과 같습니다.

https://api.sightengine.com/1.0/check.json

Enter fullscreen mode Exit fullscreen mode

요청에는 다음 값을 보냅니다.

  • media: 이미지 파일
  • models: genai
  • api_user
  • api_secret

응답의 type.ai_generated 값을 AI 생성 점수로 사용합니다.

classifier.py

# 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 생성 점수를 반환하고,
    실패 시 available: False와 reason을 반환한다.
    """
    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

이 함수는 비동기입니다. 느린 외부 API 호출이 FastAPI 이벤트 루프를 막지 않게 하기 위해서입니다.

운영에서는 다음을 조정하세요.

  • 타임아웃
  • 재시도 여부
  • 공급업체별 에러 매핑
  • API 키 관리 방식
  • 요청당 비용을 고려한 rate limit

점수는 항상 추정치로 다뤄야 합니다. 실제 동작 방식은 AI 생성 이미지 확인 방법에서도 다룹니다.

/verify 계약 설계

이제 구현 전에 API 계약을 정의합니다. 이 단계에서 Apidog를 사용하면 좋습니다.

스키마를 먼저 만들면 다음 이점이 있습니다.

  • 프론트엔드와 백엔드가 같은 계약을 공유합니다.
  • 백엔드 구현 전에도 모의 서버를 만들 수 있습니다.
  • 구현 후 같은 스키마 기반으로 테스트를 실행할 수 있습니다.

요청

POST /verify는 하나의 파일 필드를 받습니다.

POST /verify
Content-Type: multipart/form-data

Enter fullscreen mode Exit fullscreen mode

필드:

이름 타입 설명
image file 검증할 이미지

응답

응답은 최종 판정과 두 원시 신호를 모두 포함해야 합니다.

{
  "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": "유효한 C2PA 매니페스트가 AI 이미지 도구를 명시하며, 분류기가 해당 이미지를 AI 생성으로 추정합니다.",
  "checked_at": "2026-05-21T09:30:00Z"
}

Enter fullscreen mode Exit fullscreen mode

verdict는 세 가지 값 중 하나입니다.

의미
likely_authentic 진품으로 추정
likely_ai AI 생성으로 추정
uncertain 판단 불가 또는 신호 충돌

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 매니페스트는 강한 신호입니다.
  • 매니페스트가 AI 도구를 명시하면 likely_ai입니다.
  • 매니페스트가 비 AI 도구를 명시하지만 분류기가 강하게 반대하면 uncertain입니다.
  • 유효하지 않은 매니페스트는 uncertain입니다.
  • 매니페스트가 없으면 분류기 점수에 의존합니다.
  • 두 신호가 모두 없으면 uncertain입니다.

verdict.py

# verdict.py


def combine_signals(provenance: dict, classifier: dict) -> dict:
    """출처 신호와 분류기 신호를 하나의 판정으로 결합한다."""
    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)

    # 1. 유효한 매니페스트가 AI 생성 도구를 명시하는 경우
    if has_manifest and validation == "valid" and generator_looks_ai:
        return _verdict(
            "likely_ai",
            0.95,
            "A valid C2PA manifest names an AI image tool.",
        )

    # 2. 유효한 매니페스트가 카메라 또는 비 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,
                "Manifest looks authentic but the classifier disagrees; signals conflict.",
            )

        return _verdict(
            "likely_authentic",
            0.9,
            "A valid C2PA manifest from a non-AI tool is present.",
        )

    # 3. 매니페스트가 있지만 검증 실패한 경우
    if has_manifest and validation in ("invalid", "error"):
        return _verdict(
            "uncertain",
            0.6,
            "The image carries a C2PA manifest that failed validation; its claimed history is unverified.",
        )

    # 4. 매니페스트가 없으면 분류기에 의존
    if classifier_ok and ai_score is not None:
        if ai_score >= 0.7:
            return _verdict(
                "likely_ai",
                round(ai_score, 2),
                "No provenance data; the classifier scored the image as likely AI-generated.",
            )

        if ai_score <= 0.3:
            return _verdict(
                "likely_authentic",
                round(1 - ai_score, 2),
                "No provenance data; the classifier scored the image as likely authentic.",
            )

        return _verdict(
            "uncertain",
            0.5,
            "No provenance data and the classifier score is inconclusive.",
        )

    # 5. 두 신호 모두 없는 경우
    return _verdict(
        "uncertain",
        0.0,
        "No provenance data and the classifier was unavailable.",
    )


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

Enter fullscreen mode Exit fullscreen mode

임계값은 제품 정책에 맞게 조정해야 합니다. 예를 들어 뉴스룸, 보험 청구, 소셜 플랫폼은 서로 다른 위험 허용치를 가질 수 있습니다.

FastAPI 앱 구현

이제 /verify 엔드포인트를 구현합니다.

main.py

# 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  # 12 MB

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(...)):
    # 1. 업로드 검증
    if image.content_type not in ALLOWED_TYPES:
        raise HTTPException(
            status_code=415,
            detail=(
                f"Unsupported type {image.content_type}. "
                "Send JPEG, PNG, or WebP."
            ),
        )

    image_bytes = await image.read()

    if len(image_bytes) == 0:
        raise HTTPException(status_code=400, detail="Empty file.")

    if len(image_bytes) > MAX_BYTES:
        raise HTTPException(status_code=413, detail="File exceeds 12 MB limit.")

    # 2. C2PA 출처 신호
    # c2pa.Reader는 파일 경로를 필요로 하므로 임시 파일을 사용한다.
    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)

    # 3. 분류기 신호
    # 분류기 실패는 예외가 아니라 available: False로 처리한다.
    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",
        }

    # 4. 신호 결합
    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

환경 변수도 설정합니다.

export SIGHTENGINE_API_USER="your_api_user"
export SIGHTENGINE_API_SECRET="your_api_secret"

Enter fullscreen mode Exit fullscreen mode

이 백엔드는 작지만 API 제품의 핵심 패턴을 따릅니다. 명확한 계약을 가진 작은 서비스가 핵심 기능을 노출합니다. 이 관점은 헤드리스(headless)화되는 소프트웨어에서도 다룹니다.

Apidog로 모의 서버 만들기

프론트엔드는 업로드 UI와 결과 패널을 바로 만들고 싶지만, 실제 백엔드는 API 키 발급, 구현, 배포가 필요할 수 있습니다. 이때 스키마 기반 모의 서버가 유용합니다.

작업 흐름은 다음과 같습니다.

  1. OpenAPI 스키마를 Apidog에 가져옵니다.
  2. /verify 엔드포인트를 생성합니다.
  3. 요청 본문을 multipart/form-data로 설정합니다.
  4. image 필드를 파일 타입으로 정의합니다.
  5. 위의 VerifyResponse 스키마를 응답으로 연결합니다.
  6. Apidog 모의 서버를 생성합니다.
  7. 프론트엔드는 실제 백엔드 대신 모의 서버 URL을 호출합니다.

모의 응답 예시는 최소한 다음 케이스를 준비하세요.

  • 유효한 카메라 매니페스트가 있는 likely_authentic
  • AI 도구가 명시된 매니페스트가 있는 likely_ai
  • 분류기를 사용할 수 없는 uncertain
  • 지원하지 않는 파일 형식에 대한 415
  • 너무 큰 파일에 대한 413

이렇게 하면 백엔드 구현 전에도 UI 상태를 모두 만들 수 있습니다.

Apidog에서 엔드포인트 테스트하기

백엔드가 실행되면 Apidog에서 실제 요청을 만듭니다.

  1. 메서드: POST
  2. URL: http://127.0.0.1:8000/verify
  3. Body: form-data
  4. 필드 이름: image
  5. 필드 타입: File
  6. 테스트 이미지 선택
  7. 요청 전송

그다음 반복 가능한 테스트가 되도록 어설션을 추가합니다.

예시 어설션:

  • 응답 상태가 200인지 확인
  • verdict가 존재하는지 확인
  • verdictlikely_authentic, likely_ai, uncertain 중 하나인지 확인
  • confidence0 이상 1 이하인지 확인
  • signals.provenance.has_manifest가 boolean인지 확인
  • signals.classifier.available이 boolean인지 확인

추가로 다음 시나리오를 저장해두면 좋습니다.

  • C2PA 매니페스트가 있는 이미지
  • 매니페스트가 없는 일반 JPEG
  • 너무 큰 파일
  • .jpg로 이름만 바꾼 비이미지 파일
  • 분류기 API 키가 설정되지 않은 환경
  • 분류기 타임아웃 상황

수동 curl 테스트는 금방 번거로워집니다. 저장된 Apidog 시나리오는 변경 후 반복 실행하거나 CI에 연결하기 쉽습니다.

강화 및 엣지 케이스

이미지 검증 엔드포인트는 적대적 입력을 받는다고 가정해야 합니다.

손상되거나 잘린 파일

MIME 타입은 이미지처럼 보이지만 실제 내용은 깨져 있을 수 있습니다.

현재 예시는 C2PA 리더의 오류를 처리하지만, 운영에서는 Pillow 같은 라이브러리로 실제 이미지 디코딩까지 검증하는 것이 좋습니다.

예시:

from PIL import Image
from io import BytesIO

def validate_image_bytes(image_bytes: bytes) -> None:
    try:
        with Image.open(BytesIO(image_bytes)) as img:
            img.verify()
    except Exception:
        raise HTTPException(status_code=400, detail="Invalid image file.")

Enter fullscreen mode Exit fullscreen mode

이 검사를 추가하려면 Pillow를 설치합니다.

pip install pillow

Enter fullscreen mode Exit fullscreen mode

매니페스트 누락

가장 흔한 케이스입니다.

매니페스트가 없다는 것은 다음을 의미하지 않습니다.

  • AI 생성이라는 뜻이 아님
  • 진짜 사진이라는 뜻도 아님
  • 서버 오류도 아님

그냥 “출처 신호 없음”입니다. 이 경우 분류기 신호로 넘어가야 합니다.

분류기 타임아웃

분류기는 외부 네트워크 의존성입니다. 실패할 수 있다고 가정해야 합니다.

classify_image()는 타임아웃이나 HTTP 오류를 예외로 전파하지 않고 다음 형태로 반환합니다.

{
  "available": false,
  "reason": "classifier_timeout"
}

Enter fullscreen mode Exit fullscreen mode

판정 함수는 이 상태를 읽고 가능한 경우 C2PA 신호만으로 판정하거나, 그것도 없으면 uncertain을 반환합니다.

변조된 매니페스트

매니페스트가 존재해도 유효하지 않을 수 있습니다.

예를 들어:

  • 서명이 잘못됨
  • 인증서 체인이 신뢰되지 않음
  • 이미지 해시가 매니페스트와 일치하지 않음

이때 validation_status가 채워집니다. 유효성 검증에 실패한 매니페스트는 신뢰하면 안 됩니다. 위 예시는 이를 uncertain으로 처리합니다.

대용량 파일과 남용

업로드 크기를 제한해야 합니다. 예시는 12MB를 사용합니다.

운영에서는 추가로 다음을 고려하세요.

  • API gateway 또는 reverse proxy에서 body size 제한
  • 사용자 또는 IP 기반 rate limit
  • 인증 필요 여부
  • 외부 분류기 호출 비용 제한
  • 요청 큐 또는 비동기 처리

개인 정보 보호

이미지는 민감한 데이터일 수 있습니다.

기본 원칙은 다음과 같습니다.

  • 이미지 바이트를 로그에 남기지 않습니다.
  • 임시 파일은 즉시 삭제합니다.
  • 필요하지 않으면 원본 이미지를 저장하지 않습니다.
  • 타사 분류기에 이미지를 전송한다는 사실을 개인정보 처리방침에 명시합니다.
  • 규제 산업에서는 외부 전송이 허용되는지 확인합니다.

각 신호가 포착하고 놓치는 것

시나리오 C2PA 출처 신호 분류기 신호
콘텐츠 자격 증명을 작성하는 도구로 만든 AI 이미지 포착: 매니페스트에 생성기 명시 대개 포착: 아티팩트 존재
메타데이터가 제거된 AI 이미지 (스크린샷, 재업로드) 놓침: 읽을 매니페스트 없음 포착: 픽셀로 작동, 메타데이터 불필요
콘텐츠 자격 증명을 서명하는 카메라로 찍은 실제 사진 확인: 유효한 매니페스트, 비 AI 생성기 심한 압축 또는 편집 시 오탐할 수 있음
메타데이터가 전혀 없는 실제 사진 신호 없음: 검증할 것 없음 최고의 추정치만: 확률적이며, 틀릴 수 있음
위조되거나 변조된 매니페스트가 있는 이미지 포착: validation_status가 실패를 표시 포착할 수도 안 할 수도 있음, 픽셀에 따라 다름
분류기가 훈련되지 않은 새로운 생성기 도구가 매니페스트를 작성하는 경우에만 포착 종종 놓침: 훈련 분포를 벗어남
심하게 편집된 실제 사진 (실제 기반 위에 AI 리터칭) 매니페스트가 있는 경우, 편집 이력을 기록 모호함: 부분적으로 합성, 점수가 중간 범위

핵심은 간단합니다.

  • C2PA는 정확하지만 항상 존재하지 않습니다.
  • 분류기는 거의 항상 적용할 수 있지만 확률적입니다.
  • 두 신호가 약하거나 충돌하면 uncertain이 올바른 답입니다.

실제 사용 사례

이 패턴은 다음 제품에 바로 적용할 수 있습니다.

사용자 생성 콘텐츠 플랫폼

마켓플레이스나 소셜 앱은 업로드된 이미지를 /verify에 전달하고, likely_ai 또는 검증 실패 매니페스트가 있는 이미지를 검토 대기열로 보낼 수 있습니다.

판정 값은 정책에 쉽게 매핑됩니다.

verdict 처리 예시
likely_authentic 허용
likely_ai 라벨링 또는 검토
uncertain 사람 검토

뉴스룸 및 팩트 체크

편집자는 바이럴 이미지에 대해 다음을 한 번에 얻을 수 있습니다.

  • C2PA 출처 정보
  • AI 탐지 점수
  • 사람이 읽을 수 있는 설명 문장
  • 감사 가능한 원시 신호

보험 및 청구 접수

고객이 제출한 사진 증거에 대해 생성 이미지 가능성이나 변조된 매니페스트를 사전에 플래그할 수 있습니다.

내부 자산 파이프라인

스톡 이미지, 캠페인 이미지, 제품 이미지 파이프라인에서 AI 생성 이미지를 자동으로 라벨링하거나 차단할 수 있습니다.

출처 인식 퍼블리싱

CMS는 업로드 시 매니페스트를 읽고 검증된 출처 배지를 표시할 수 있습니다. 매니페스트가 없으면 분류기 결과를 보조 신호로 사용할 수 있습니다.

결론

AI 생성 이미지 탐지는 완벽한 단일 테스트를 찾는 문제가 아닙니다. 독립적인 신호를 결합하고, 불확실성을 명시적으로 다루는 문제입니다.

구현 요약은 다음과 같습니다.

  • C2PA 콘텐츠 자격 증명은 암호화로 검증 가능한 강한 출처 신호입니다.
  • 하지만 매니페스트는 옵트인이고 쉽게 제거될 수 있습니다.
  • 호스팅 분류기는 메타데이터 없이도 동작하지만 확률적입니다.
  • FastAPI 서비스에서 두 신호를 결합하면 likely_authentic, likely_ai, uncertain 판정을 반환할 수 있습니다.
  • OpenAPI 계약을 먼저 설계하면 Apidog에서 모의 서버와 테스트를 먼저 만들 수 있습니다.
  • uncertain은 실패가 아니라 정직한 결과입니다.

실제로 시작하려면 /verify 스키마를 먼저 정의하고, Apidog에서 모의 서버를 만든 뒤, 백엔드 구현 후 같은 계약으로 엔드포인트 테스트를 실행하세요. 프론트엔드는 모의 URL로 먼저 통합하고, 배포 후 기본 URL만 실제 백엔드로 바꾸면 됩니다.