DEV Community

Cover image for Criar API Detector de Imagens IA com C2PA e Classificador
Lucas
Lucas

Posted on • Originally published at apidog.com

Criar API Detector de Imagens IA com C2PA e Classificador

Alguém envia uma foto para o seu produto e afirma que ela veio de uma câmera. Seu backend consegue validar isso? Em vez de “confiar nos olhos”, construa um endpoint que combine dois sinais independentes: um manifesto C2PA/Content Credentials validado criptograficamente e um classificador de imagem gerada por IA. O resultado é um veredito mais útil e auditável do que qualquer sinal isolado.

Experimente o Apidog hoje

Neste tutorial, você vai implementar um serviço FastAPI com POST /verify. Ele recebe uma imagem, tenta extrair e validar o manifesto C2PA, chama uma API hospedada de detecção de IA e retorna um JSON com veredito, confiança e sinais brutos. Como é uma API, também vamos desenhar o contrato OpenAPI primeiro e usar o Apidog para mockar e testar o endpoint antes do backend estar pronto.

TL;DR

Você vai construir:

  • um endpoint POST /verify com upload multipart/form-data;
  • leitura de Content Credentials com c2pa-python;
  • chamada a um classificador hospedado, como Sightengine;
  • uma função de decisão que retorna:
    • provavelmente_autêntico;
    • provavelmente_ia;
    • incerto;
  • um contrato OpenAPI para mock e testes no Apidog.

Por que usar dois sinais?

Não existe uma propriedade única em um arquivo que prove “foi feito por humano” ou “foi feito por IA”. Existem pistas.

Sinal 1: proveniência C2PA

C2PA, da Coalition for Content Provenance and Authenticity, é um padrão aberto para anexar metadados assinados criptograficamente a arquivos de mídia. Esses metadados são conhecidos para usuários finais como Content Credentials.

Quando uma câmera, editor ou gerador compatível cria ou altera uma imagem, ele pode escrever um manifesto com o histórico da mídia e assiná-lo. Se o manifesto estiver presente e válido, você tem uma declaração forte sobre a origem e as transformações da imagem.

Limitação: C2PA é opt-in e frágil. Screenshots, recodificações e uploads em algumas plataformas podem remover o manifesto. Portanto, ausência de manifesto não significa que a imagem é falsa nem que é verdadeira.

Sinal 2: classificador estatístico

Um classificador analisa os pixels e estima a probabilidade de a imagem ter sido gerada por IA. Ele funciona mesmo sem metadados, mas retorna probabilidade, não prova.

Limitação: pode errar em imagens comprimidas, editadas ou fora da distribuição de treinamento.

A combinação ideal é:

“O que a criptografia prova, o que o modelo estima e quão forte é a conclusão combinada.”

Para entender melhor falhas de abordagem de sinal único, veja por que a detecção de imagem de IA falha.

Arquitetura

                ┌─────────────────────────────┐
   image  ──▶   │   FastAPI  POST /verify      │
                │                              │
                │   1. validate upload         │
                │   2. ┌──────────────────┐    │
                │      │ C2PA manifest     │    │  provenance signal
                │      │ c2pa-python       │    │
                │      └──────────────────┘    │
                │   3. ┌──────────────────┐    │
                │      │ classifier API    │    │  statistical signal
                │      │ hosted detector   │    │
                │      └──────────────────┘    │
                │   4. combine into verdict    │
                └─────────────────────────────┘
                              │
                              ▼
                   JSON verdict + confidence
Enter fullscreen mode Exit fullscreen mode

O fluxo:

  1. Validar tipo e tamanho do arquivo.
  2. Ler o manifesto C2PA localmente.
  3. Enviar a imagem para um classificador hospedado.
  4. Combinar os sinais em um veredito único.

Instale as dependências:

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

Requisitos principais:

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

Implementando o sinal C2PA

A Content Authenticity Initiative mantém ferramentas C2PA open source sob a organização contentauth.

Você verá dois nomes:

  • c2patool: CLI para inspecionar e adicionar manifestos. O repositório independente foi arquivado; a CLI vive no projeto Rust c2pa-rs.
  • c2pa-python: binding Python da biblioteca Rust c2pa-rs. É o que vamos usar no serviço.

Crie provenance.py:

# provenance.py
import json
import c2pa


def read_provenance(image_path: str) -> dict:
    """
    Lê e valida o manifesto C2PA de uma imagem.
    Retorna um dict normalizado com o que foi encontrado.
    """
    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": "Nenhum manifesto C2PA presente nesta imagem.",
            }

        return {
            "has_manifest": True,
            "validation": "error",
            "detail": f"Não foi possível analisar o manifesto: {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": "Manifesto lido com sucesso.",
    }
Enter fullscreen mode Exit fullscreen mode

Pontos importantes:

  • Reader é usado como context manager para liberar recursos corretamente.
  • reader.json() retorna o manifesto completo em JSON.
  • ManifestNotFound é esperado na maioria das imagens.
  • Manifesto ausente não é erro.
  • validation_status vazio indica validação bem-sucedida.
  • validation_status preenchido indica problema de assinatura, hash ou integridade.

Implementando o classificador

Este exemplo usa Sightengine porque a API HTTP é documentada e retorna uma pontuação clara em type.ai_generated.

O padrão é o mesmo para outros fornecedores: trocar URL, parâmetros e campo de leitura.

Para comparar alternativas, veja melhores APIs de detecção de imagem de IA.

Crie 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:
    """
    Envia a imagem para o detector hospedado.
    Retorna um dict normalizado com a pontuação de IA.
    """
    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

Decisões de implementação:

  • a função é assíncrona;
  • o timeout é explícito;
  • falhas retornam available: False;
  • uma falha do classificador não deve derrubar o endpoint;
  • a pontuação deve ser tratada como estimativa, não como prova.

Para contexto adicional, veja como verificar se uma imagem é gerada por IA.

Projetando o contrato POST /verify

Antes de escrever a rota, defina o contrato da API.

Com o Apidog, você pode:

  • criar o endpoint visualmente;
  • importar um arquivo OpenAPI;
  • gerar um servidor mock;
  • compartilhar o contrato com frontend;
  • salvar cenários de teste.

Request

POST /verify recebe multipart/form-data com um campo:

image: File
Enter fullscreen mode Exit fullscreen mode

Response

Exemplo de resposta:

{
  "verdict": "provavelmente_ia",
  "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": "Um manifesto C2PA válido nomeia uma ferramenta de imagem de IA, e o classificador pontuou a imagem como provavelmente gerada por IA.",
  "checked_at": "2026-05-21T09:30:00Z"
}
Enter fullscreen mode Exit fullscreen mode

verdict deve ser um destes valores:

  • provavelmente_autêntico;
  • provavelmente_ia;
  • incerto.

Use três estados. Quando os sinais discordam ou são fracos, incerto é o resultado correto.

Esquema OpenAPI

components:
  schemas:
    VerifyResponse:
      type: object
      required: [verdict, confidence, signals, checked_at]
      properties:
        verdict:
          type: string
          enum: [provavelmente_autêntico, provavelmente_ia, incerto]
        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

Se você prefere um fluxo spec-first, veja o passo a passo do modo spec-first.

Combinando os sinais

Crie verdict.py:

# verdict.py


def combine_signals(provenance: dict, classifier: dict) -> dict:
    """
    Combina proveniência e classificador em um único veredito.
    """
    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(keyword in generator for keyword in ai_keywords)

    # 1. Manifesto válido que nomeia uma ferramenta de IA.
    if has_manifest and validation == "valid" and generator_looks_ai:
        return _verdict(
            "provavelmente_ia",
            0.95,
            "Um manifesto C2PA válido nomeia uma ferramenta de imagem de IA.",
        )

    # 2. Manifesto válido de câmera ou ferramenta não-IA.
    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(
                "incerto",
                0.55,
                "O manifesto parece autêntico, mas o classificador discorda.",
            )

        return _verdict(
            "provavelmente_autêntico",
            0.9,
            "Um manifesto C2PA válido de uma ferramenta não-IA está presente.",
        )

    # 3. Manifesto presente, mas inválido ou com erro.
    if has_manifest and validation in ("invalid", "error"):
        return _verdict(
            "incerto",
            0.6,
            "A imagem possui um manifesto C2PA que falhou na validação.",
        )

    # 4. Sem manifesto: usar classificador.
    if classifier_ok and ai_score is not None:
        if ai_score >= 0.7:
            return _verdict(
                "provavelmente_ia",
                round(ai_score, 2),
                "Sem dados de proveniência; o classificador pontuou a imagem como provavelmente gerada por IA.",
            )

        if ai_score <= 0.3:
            return _verdict(
                "provavelmente_autêntico",
                round(1 - ai_score, 2),
                "Sem dados de proveniência; o classificador pontuou a imagem como provavelmente autêntica.",
            )

        return _verdict(
            "incerto",
            0.5,
            "Sem dados de proveniência e a pontuação do classificador é inconclusiva.",
        )

    # 5. Sem manifesto e sem classificador.
    return _verdict(
        "incerto",
        0.0,
        "Sem dados de proveniência e o classificador estava indisponível.",
    )


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

Essa política é conservadora:

  • manifesto válido tem peso alto;
  • manifesto inválido não prova falsidade, mas gera alerta;
  • conflito entre sinais vira incerto;
  • sem sinais, a confiança é 0.0.

Ajuste os thresholds conforme seu risco. Uma rede social, uma seguradora e uma redação provavelmente terão políticas diferentes.

Criando o app FastAPI

Crie 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="API de Detecção de Imagens de IA",
    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(...)):
    # 1. Validar tipo.
    if image.content_type not in ALLOWED_TYPES:
        raise HTTPException(
            status_code=415,
            detail=f"Tipo não suportado {image.content_type}. Envie JPEG, PNG ou WebP.",
        )

    # 2. Ler e validar tamanho.
    image_bytes = await image.read()

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

    if len(image_bytes) > MAX_BYTES:
        raise HTTPException(
            status_code=413,
            detail="O arquivo excede o limite de 12 MB.",
        )

    # 3. Ler proveniência.
    # c2pa-python precisa de um caminho de arquivo.
    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)

    # 4. Chamar classificador.
    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",
        }

    # 5. Combinar sinais.
    result = combine_signals(provenance, classifier)

    return JSONResponse(
        {
            "verdict": result["verdict"],
            "confidence": result["confidence"],
            "signals": {
                "provenance": {
                    key: provenance.get(key)
                    for key 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

Execute localmente:

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

Endpoint:

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

Configure as credenciais do classificador:

export SIGHTENGINE_API_USER="seu_usuario"
export SIGHTENGINE_API_SECRET="seu_secret"
Enter fullscreen mode Exit fullscreen mode

Esse design segue um padrão comum de produto API-first: uma capacidade central exposta por uma API pequena e bem definida. Para contexto, veja software headless.

Testando com curl

Teste com uma imagem local:

curl -X POST http://127.0.0.1:8000/verify \
  -F "image=@./foto.jpg"
Enter fullscreen mode Exit fullscreen mode

Resposta esperada:

{
  "verdict": "incerto",
  "confidence": 0.5,
  "signals": {
    "provenance": {
      "has_manifest": false,
      "validation": "none",
      "claim_generator": null,
      "signature_issuer": null
    },
    "classifier": {
      "available": true,
      "ai_score": 0.48
    }
  },
  "explanation": "Sem dados de proveniência e a pontuação do classificador é inconclusiva.",
  "checked_at": "2026-05-21T09:30:00+00:00"
}
Enter fullscreen mode Exit fullscreen mode

Teste erro de tipo:

curl -X POST http://127.0.0.1:8000/verify \
  -F "image=@./arquivo.txt"
Enter fullscreen mode Exit fullscreen mode

Se o content_type for detectado como não suportado, o endpoint retorna 415.

Mockando o endpoint no Apidog

O frontend não precisa esperar o backend ficar pronto.

Fluxo recomendado:

  1. Crie o endpoint POST /verify no Apidog.
  2. Defina o body como multipart/form-data.
  3. Adicione o campo image como arquivo.
  4. Defina o schema VerifyResponse.
  5. Crie exemplos de resposta.
  6. Gere o mock server.
  7. Entregue a URL mock para o frontend.

Crie exemplos para:

  • provavelmente_autêntico com manifesto válido de câmera;
  • provavelmente_ia com manifesto que nomeia ferramenta de IA;
  • incerto com classificador indisponível;
  • erro 413 para arquivo grande;
  • erro 415 para tipo não suportado.

Assim, o frontend consegue implementar upload, loading, estados de erro e painel de resultado antes do backend real existir.

Quando o backend for lançado, basta trocar a base URL.

Testando o backend real no Apidog

Depois que o serviço estiver rodando:

  1. Crie uma request POST /verify.
  2. Aponte para http://127.0.0.1:8000/verify.
  3. Em Body, escolha form-data.
  4. Adicione image.
  5. Defina o tipo como File.
  6. Selecione uma imagem local.
  7. Envie.

Adicione asserções:

  • status HTTP é 200;
  • verdict existe;
  • verdict está entre provavelmente_autêntico, provavelmente_ia e incerto;
  • confidence é número entre 0 e 1;
  • signals.provenance.has_manifest é booleano;
  • signals.classifier.available é booleano.

Monte um cenário com múltiplos uploads:

  • imagem com Content Credentials;
  • JPEG comum sem manifesto;
  • arquivo grande;
  • arquivo não-imagem renomeado como .jpg;
  • imagem com classificador indisponível.

Isso transforma testes manuais em uma suíte repetível.

Reforço e casos limite

Arquivos corrompidos

Um arquivo pode declarar image/jpeg e ainda assim ser inválido. Para reforçar, decodifique a imagem antes de processar.

Exemplo com Pillow:

pip install pillow
Enter fullscreen mode Exit fullscreen mode
from PIL import Image, UnidentifiedImageError
from io import BytesIO


def validate_image_bytes(image_bytes: bytes) -> None:
    try:
        with Image.open(BytesIO(image_bytes)) as img:
            img.verify()
    except UnidentifiedImageError:
        raise ValueError("Arquivo não é uma imagem válida.")
Enter fullscreen mode Exit fullscreen mode

Você pode chamar essa função antes da etapa C2PA e retornar 400 se falhar.

Manifesto ausente

Esse é o caso mais comum.

Não trate como erro. Não retorne 500. Não conclua que a imagem é falsa.

Siga para o classificador.

Classificador indisponível

Assuma que a dependência externa falhará.

Boas práticas:

  • timeout curto;
  • tratamento de erro HTTP;
  • retorno available: False;
  • veredito degradado para incerto quando necessário.

Manifesto inválido

Manifesto presente não significa manifesto confiável.

Sempre verifique validation_status.

  • array vazio: manifesto validado;
  • array preenchido: falha de validação.

Um manifesto inválido deve gerar alerta, não prova automática.

Arquivos grandes e abuso

Aplique:

  • limite de tamanho;
  • rate limiting;
  • autenticação se o endpoint não for público;
  • observabilidade por status e motivo de falha;
  • limites de timeout em chamadas externas.

O exemplo usa 12 MB:

MAX_BYTES = 12 * 1024 * 1024
Enter fullscreen mode Exit fullscreen mode

Privacidade

Você está processando imagens de usuários.

Evite:

  • logar bytes da imagem;
  • persistir arquivos temporários;
  • enviar imagens a terceiros sem consentimento ou base legal;
  • esconder o uso de classificador externo da política de privacidade.

O que cada sinal detecta e perde

Cenário Sinal de proveniência C2PA Sinal do classificador
Imagem de IA de ferramenta que escreve Content Credentials Detecta: manifesto nomeia o gerador Geralmente detecta artefatos
Imagem de IA com metadados removidos Perde: nenhum manifesto Detecta pelos pixels
Foto real de câmera que assina Content Credentials Confirma manifesto válido Pode dar falso positivo
Foto real sem metadados Sem sinal Melhor palpite probabilístico
Imagem com manifesto forjado ou adulterado Detecta via validation_status Pode ou não detectar
Gerador novo fora do treino do classificador Detecta apenas se houver manifesto Pode falhar
Foto real fortemente editada com IA Manifesto pode registrar histórico Pode ficar ambíguo

A proveniência é precisa, mas esparsa. O classificador é amplo, mas probabilístico. O veredito combinado é mais útil do que qualquer coluna sozinha.

Casos de uso

Plataformas de conteúdo gerado por usuário

Use /verify no upload e mapeie resultados:

  • provavelmente_autêntico: permitir;
  • provavelmente_ia: rotular ou revisar;
  • incerto: enviar para revisão humana.

Redações e fact-checking

Um editor pode obter em uma única chamada:

  • manifesto C2PA, se houver;
  • validação criptográfica;
  • pontuação do classificador;
  • explicação legível.

Seguros e sinistros

Antes de um analista humano revisar evidências fotográficas, o sistema pode sinalizar:

  • imagens provavelmente geradas;
  • manifestos adulterados;
  • ausência de sinais fortes.

Pipelines internos de assets

Equipes que precisam manter imagens de IA fora de bibliotecas internas podem usar o endpoint como gate de ingestão.

CMS com proveniência

À medida que mais câmeras e editores adotam Content Credentials, um CMS pode exibir selos de proveniência verificada e recorrer ao classificador quando não houver manifesto.

Conclusão

Detectar imagens geradas por IA não exige um teste perfeito. Exige combinar sinais independentes e comunicar incerteza.

Neste tutorial, você implementou:

  • leitura e validação de manifesto C2PA;
  • chamada a classificador hospedado;
  • função de decisão com três vereditos;
  • endpoint FastAPI POST /verify;
  • contrato OpenAPI;
  • mock e testes com Apidog.

O ponto principal: incerto não é falha do produto. É uma resposta honesta quando os sinais não sustentam uma conclusão forte.

Para construir isso com menos bloqueio entre backend e frontend, modele o contrato /verify, gere um mock server e salve cenários de teste no Apidog.

Top comments (0)