Alguien sube una foto a tu producto y afirma que fue tomada con una cámara. ¿Puede tu backend probarlo o refutarlo? Hoy los generadores de imágenes producen resultados creíbles para revisores humanos, así que “confiar en los ojos” ya no basta. La forma práctica de abordar el problema es combinar dos señales independientes: un manifiesto de procedencia criptográfico y un clasificador de aprendizaje automático.
En este tutorial construirás un servicio FastAPI con un endpoint POST /verify. Le envías una imagen y devuelve un veredicto JSON con una puntuación de confianza y los detalles de procedencia encontrados. Usaremos Python, FastAPI, herramientas C2PA de código abierto y una API alojada de detección de imágenes generadas por IA. Como es un proyecto de API, primero diseñaremos el contrato del endpoint y usaremos Apidog para simularlo y probarlo antes de terminar el backend.
TL;DR
Construirás un servicio FastAPI que expone POST /verify y:
- Acepta una imagen por
multipart/form-data. - Extrae y valida su manifiesto de Credenciales de Contenido C2PA con
c2pa-python. - Llama a un clasificador alojado como segunda señal independiente.
- Devuelve un veredicto JSON:
likely_authenticlikely_aiuncertain
- Incluye una puntuación de confianza y las señales sin procesar.
- Define el esquema OpenAPI y lo usa en Apidog para mockear y probar el endpoint.
Por qué usar dos señales
No existe una propiedad única en un archivo que diga “esto lo hizo un humano” o “esto lo generó una IA”. Hay señales, y cada una cubre casos distintos.
Señal 1: procedencia C2PA
C2PA, la Coalición para la Procedencia y Autenticidad del Contenido, es un estándar abierto que adjunta metadatos criptográficamente firmados a un archivo multimedia. Ese paquete se conoce como manifiesto; de cara al usuario suele aparecer como Credenciales de Contenido.
Una cámara, editor o generador compatible puede escribir un manifiesto que registre qué ocurrió con la imagen y firmarlo con un certificado. Si puedes leer y validar ese manifiesto, obtienes una declaración fuerte sobre el historial de la imagen.
El problema: C2PA es opcional y los manifiestos son frágiles. Una captura de pantalla, una recompresión o una subida a una plataforma que elimina metadatos pueden borrarlos. Por eso, la ausencia de manifiesto no prueba nada.
Señal 2: clasificador estadístico
Un clasificador de detección de IA analiza los píxeles y estima la probabilidad de que una imagen haya sido generada por IA. Funciona aunque no haya metadatos, pero es probabilístico. Puede equivocarse, especialmente con imágenes muy comprimidas, editadas o fuera de su distribución de entrenamiento.
La estrategia útil es combinar ambas señales:
- La procedencia es precisa, pero a menudo no está presente.
- El clasificador siempre puede ejecutarse, pero no es una prueba.
- El veredicto combinado puede decir: “esto es lo que prueba la criptografía, esto es lo que estima el modelo y esta es la confianza resultante”.
Si quieres profundizar en los límites de los detectores de una sola señal, consulta por qué falla la detección de imágenes de IA.
Arquitectura del servicio
El servicio tendrá un solo endpoint y dos señales independientes.
┌─────────────────────────────┐
imagen ──▶ │ FastAPI POST /verify │
│ │
│ 1. validar la carga │
│ 2. ┌──────────────────┐ │
│ │ manifiesto C2PA │ │ señal de procedencia
│ │ (c2pa-python) │ │
│ └──────────────────┘ │
│ 3. ┌──────────────────┐ │
│ │ API clasificador │ │ señal estadística
│ │ detector alojado │ │
│ └──────────────────┘ │
│ 4. combinar veredicto │
└─────────────────────────────┘
│
▼
Veredicto JSON + confianza
Flujo:
- Validar que la carga sea una imagen soportada y tenga un tamaño permitido.
- Leer localmente el manifiesto C2PA, si existe.
- Enviar la imagen a un clasificador alojado por HTTPS.
- Combinar resultados en un veredicto.
Instala las dependencias:
pip install fastapi "uvicorn[standard]" python-multipart httpx c2pa-python
Necesitas Python 3.10 o superior.
Implementar la señal C2PA
La organización contentauth publica herramientas C2PA de código abierto. Hay dos piezas relevantes:
-
c2patool: CLI para inspeccionar o añadir manifiestos. Su repositorio independiente está archivado; la CLI vive enc2pa-rs. -
c2pa-python: binding de Python para la librería Rustc2pa-rs. Este backend usará este paquete.
Crea provenance.py:
# provenance.py
import json
import c2pa
def read_provenance(image_path: str) -> dict:
"""
Lee y valida el manifiesto C2PA de una imagen.
Devuelve un diccionario normalizado.
"""
try:
with c2pa.Reader(image_path) as reader:
manifest_store = json.loads(reader.json())
except c2pa.C2paError as err:
# ManifestNotFound es el caso esperado para muchas imágenes.
if str(err).startswith("ManifestNotFound"):
return {
"has_manifest": False,
"validation": "none",
"detail": "No hay manifiesto C2PA presente en esta imagen.",
}
# Hay datos C2PA, pero no se pudieron analizar o validar.
return {
"has_manifest": True,
"validation": "error",
"detail": f"No se pudo analizar el manifiesto: {err}",
}
active_label = manifest_store.get("active_manifest")
manifests = manifest_store.get("manifests", {})
active = manifests.get(active_label, {})
# validation_status aparece cuando hay problemas de validación.
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": "Manifiesto leído correctamente.",
}
Puntos importantes:
-
Readerse usa como context manager para liberar recursos. -
reader.json()devuelve el almacén de manifiestos como JSON. -
reader.detailed_json()existe si necesitas más detalle. -
ManifestNotFoundno es un fallo de servidor; es un estado esperado. -
validation_statusvacío indica que la firma y los hashes validaron. - Un manifiesto inválido debe tratarse como señal de alerta, no como prueba concluyente.
Implementar la señal del clasificador
El clasificador será una API alojada que devuelve la probabilidad de que una imagen sea generada por IA. Este ejemplo usa Sightengine porque tiene una API HTTP documentada y una respuesta clara, pero puedes reemplazarla por cualquier proveedor.
Si estás comparando opciones, revisa esta guía sobre las mejores APIs de detección de imágenes de IA.
Endpoint usado:
https://api.sightengine.com/1.0/check.json
Parámetros:
-
media: archivo de imagen. -
models:genai. -
api_user: usuario API. -
api_secret: secreto API.
La respuesta incluye type.ai_generated, una puntuación entre 0 y 1.
Crea 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:
"""
Envía la imagen al detector alojado.
Devuelve una puntuación normalizada.
"""
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)}
El clasificador debe degradar el veredicto, no romper la solicitud. Por eso la función devuelve available: False en lugar de lanzar una excepción.
Una puntuación como 0.92 no significa “probado que es IA”; significa “el modelo estima alta probabilidad”. Trata el resultado como una señal, no como una verdad absoluta. Para más contexto, consulta cómo verificar si una imagen es generada por IA.
Diseñar el contrato de /verify
Antes de escribir el handler, diseña el contrato OpenAPI. Aquí Apidog es útil porque permite:
- Definir el contrato de la API.
- Generar un servidor simulado.
- Probar el endpoint real con escenarios guardados.
- Permitir que frontend integre antes de que backend esté listo.
Solicitud
POST /verify recibe multipart/form-data con un campo:
image
Tipo: archivo.
Respuesta esperada
Ejemplo:
{
"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": "Un manifiesto C2PA válido nombra una herramienta de imagen de IA, y el clasificador puntuó la imagen como probablemente generada por IA.",
"checked_at": "2026-05-21T09:30:00Z"
}
verdict tiene tres valores posibles:
likely_authenticlikely_aiuncertain
Usar tres estados es importante. Cuando las señales no concuerdan o son débiles, uncertain es una salida correcta.
Esquema OpenAPI
Componente de respuesta:
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
Puedes crear esto en el diseñador visual de Apidog o importar un archivo OpenAPI existente. Si quieres adoptar este flujo, revisa el tutorial de modo spec-first.
Combinar las señales
La política de decisión vive en una función separada. La procedencia válida domina porque es criptográfica; el clasificador actúa como respaldo o desempate.
Crea verdict.py:
# verdict.py
def combine_signals(provenance: dict, classifier: dict) -> dict:
"""Combina procedencia y clasificador en un veredicto."""
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")
# Heurística: muchas herramientas de IA se identifican en el manifiesto.
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. Manifiesto válido que nombra una herramienta de IA.
if has_manifest and validation == "valid" and generator_looks_ai:
return _verdict(
"likely_ai",
0.95,
"Un manifiesto C2PA válido nombra una herramienta de imagen de IA.",
)
# 2. Manifiesto válido de cámara o herramienta no-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(
"uncertain",
0.55,
"El manifiesto parece auténtico pero el clasificador no está de acuerdo; "
"las señales entran en conflicto.",
)
return _verdict(
"likely_authentic",
0.9,
"Hay presente un manifiesto C2PA válido de una herramienta no-IA.",
)
# 3. Manifiesto inválido o con error.
if has_manifest and validation in ("invalid", "error"):
return _verdict(
"uncertain",
0.6,
"La imagen contiene un manifiesto C2PA que falló la validación; "
"su historial declarado no está verificado.",
)
# 4. Sin manifiesto: usar clasificador.
if classifier_ok and ai_score is not None:
if ai_score >= 0.7:
return _verdict(
"likely_ai",
round(ai_score, 2),
"Sin datos de procedencia; el clasificador puntuó la imagen como "
"probablemente generada por IA.",
)
if ai_score <= 0.3:
return _verdict(
"likely_authentic",
round(1 - ai_score, 2),
"Sin datos de procedencia; el clasificador puntuó la imagen como "
"probablemente auténtica.",
)
return _verdict(
"uncertain",
0.5,
"Sin datos de procedencia y la puntuación del clasificador es inconcluyente.",
)
# 5. Sin manifiesto y sin clasificador.
return _verdict(
"uncertain",
0.0,
"Sin datos de procedencia y el clasificador no estaba disponible.",
)
def _verdict(verdict: str, confidence: float, explanation: str) -> dict:
return {
"verdict": verdict,
"confidence": confidence,
"explanation": explanation,
}
Los umbrales son intencionalmente conservadores. Ajusta 0.7, 0.3 y 0.85 según tu tolerancia al riesgo.
Implementar la aplicación FastAPI
Crea 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. Validar Content-Type.
if image.content_type not in ALLOWED_TYPES:
raise HTTPException(
status_code=415,
detail=(
f"Tipo no compatible {image.content_type}. "
"Envía JPEG, PNG o WebP."
),
)
# 2. Leer y validar tamaño.
image_bytes = await image.read()
if len(image_bytes) == 0:
raise HTTPException(status_code=400, detail="Archivo vacío.")
if len(image_bytes) > MAX_BYTES:
raise HTTPException(
status_code=413,
detail="El archivo excede el límite de 12 MB.",
)
# 3. Señal de procedencia.
# c2pa-python necesita una ruta de archivo.
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. Señal del clasificador.
if SIGHTENGINE_USER and SIGHTENGINE_SECRET:
classifier = await classify_image(
image_bytes,
image.filename or "upload",
SIGHTENGINE_USER,
SIGHTENGINE_SECRET,
)
else:
classifier = {
"available": False,
"reason": "classifier_not_configured",
}
# 5. Combinar señales.
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(),
}
)
Ejecuta el servidor:
uvicorn main:app --reload
Endpoint local:
http://127.0.0.1:8000/verify
Prueba rápida con curl:
curl -X POST "http://127.0.0.1:8000/verify" \
-F "image=@sample.jpg"
El endpoint no falla por un manifiesto ausente ni por un clasificador no disponible. Ambos son estados normales y se reflejan en el veredicto.
Este patrón encaja con productos que exponen capacidades centrales mediante APIs. Si te interesa esa idea, lee sobre el software que se vuelve “headless”.
Simular y probar con Apidog
El frontend no debería esperar a que el backend esté desplegado. Con un contrato OpenAPI puedes mockear /verify desde el primer día.
Crear un servidor simulado
En Apidog:
- Importa el esquema OpenAPI o crea
/verifyen el diseñador visual. - Define el body
multipart/form-datacon el campoimage. - Define el esquema
VerifyResponse. - Crea respuestas de ejemplo.
- Activa el servidor simulado.
- Entrega la URL mock al frontend.
Casos de respuesta que conviene modelar:
-
likely_authenticcon manifiesto C2PA válido de cámara. -
likely_aicon manifiesto válido de una herramienta de IA. -
uncertaincuando el clasificador no está disponible. - Error
415para tipo no soportado. - Error
413para archivo demasiado grande.
Así el frontend puede implementar estados de éxito, incertidumbre y error sin depender del backend real.
Ejecutar pruebas de endpoint
Cuando el backend esté listo:
- Crea una solicitud
POST /verifyen Apidog. - Apunta la URL a tu entorno local o staging.
- En Body selecciona
form-data. - Añade el campo
image. - Cambia el tipo del campo a archivo.
- Selecciona una imagen de prueba.
- Envía la solicitud.
Añade aserciones:
- Status code
200. -
verdictexiste. -
verdictestá enlikely_authentic,likely_ai,uncertain. -
confidencees número entre0y1. -
signals.provenance.has_manifestes booleano. -
signals.classifier.availablees booleano.
Crea un escenario con varias cargas:
- Imagen con Credenciales de Contenido.
- JPEG simple sin manifiesto.
- Archivo sobredimensionado.
- Archivo que no es imagen renombrado como
.jpg.
Esto te da una suite repetible para validar cambios en la lógica de veredicto.
Robustecimiento y casos extremos
Archivos corruptos o truncados
Un archivo puede declarar image/jpeg y aun así ser basura. c2pa-python puede devolver C2paError, pero también conviene decodificar la imagen con Pillow antes de procesarla.
Ejemplo opcional:
pip install pillow
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 HTTPException(status_code=400, detail="Archivo de imagen inválido.")
Manifiesto ausente
Es el caso más común. No debe producir un 500 ni un veredicto “falso”. Debe pasar al clasificador.
Clasificador con timeout
El clasificador es una dependencia de red. Debes asumir fallos.
La implementación ya usa:
httpx.AsyncClient(timeout=timeout_seconds)
Si falla, devuelve:
{
"available": false,
"reason": "classifier_timeout"
}
Luego el combinador puede responder uncertain o usar solo la procedencia.
Manifiestos falsificados o manipulados
Un manifiesto presente no es suficiente. Siempre valida validation_status.
- Array vacío: manifiesto válido.
- Array con errores: manifiesto inválido o no verificable.
No confíes en un manifiesto no validado.
Archivos grandes y abuso
El ejemplo limita a 12 MB:
MAX_BYTES = 12 * 1024 * 1024
También deberías añadir:
- Rate limiting.
- Autenticación si el endpoint no es público.
- Métricas de latencia y errores.
- Límites en proxy o gateway antes de llegar a FastAPI.
Privacidad
Estás procesando imágenes de usuarios. Reglas prácticas:
- No registres bytes de imagen.
- Usa archivos temporales y elimínalos.
- Documenta si envías imágenes a terceros.
- Revisa la política del proveedor de clasificación.
- Evalúa si necesitas consentimiento explícito.
Qué detecta cada señal
| Escenario | Señal de procedencia C2PA | Señal del clasificador |
|---|---|---|
| Imagen de IA de una herramienta que escribe Credenciales de Contenido | La detecta: el manifiesto nombra al generador | Normalmente la detecta: artefactos presentes |
| Imagen de IA con metadatos eliminados, como captura de pantalla o resubida | La omite: no hay manifiesto que leer | La detecta: funciona con píxeles |
| Foto real de una cámara que firma Credenciales de Contenido | La confirma: manifiesto válido, generador no-IA | Puede dar falso positivo por compresión o ediciones fuertes |
| Foto real sin metadatos | Sin señal | Solo una estimación probabilística |
| Imagen con manifiesto falsificado o manipulado | La detecta: validation_status marca el fallo |
Puede o no detectarla |
| Generador nuevo no visto por el clasificador | La detecta solo si la herramienta escribe manifiesto | Puede fallar por estar fuera de distribución |
| Foto real fuertemente editada con IA | El manifiesto, si existe, registra el historial de edición | Ambiguo: la puntuación puede quedar a mitad de rango |
La idea central: donde una señal es ciega, la otra puede aportar información. Y cuando ambas son débiles, uncertain es el resultado correcto.
Casos de uso
Este patrón encaja en varios sistemas reales:
- Plataformas de contenido generado por usuarios: etiquetar, poner en cola o revisar imágenes probablemente generadas por IA.
- Redacciones y verificación de hechos: obtener procedencia criptográfica y estimación de modelo en una sola llamada.
- Seguros y reclamaciones: marcar fotos sospechosas antes de revisión humana.
- Pipelines internos de assets: restringir o etiquetar imágenes generadas por IA en bibliotecas internas.
- CMS con procedencia: mostrar insignias verificadas cuando hay Credenciales de Contenido válidas.
Conclusión
Detectar imágenes generadas por IA no consiste en encontrar una prueba perfecta. Consiste en combinar señales independientes y exponer la incertidumbre.
Resumen de implementación:
- Usa C2PA para una señal criptográfica fuerte cuando existe un manifiesto.
- Usa un clasificador alojado para analizar imágenes sin metadatos.
- Combina ambas señales en un veredicto de tres estados.
- Devuelve siempre las señales sin procesar para auditoría.
- Diseña el contrato OpenAPI antes de implementar.
- Usa Apidog para simular el endpoint y ejecutar pruebas repetibles.
Ningún detector es perfecto. Dos señales aumentan la confianza, pero no eliminan la incertidumbre. Por eso uncertain no es una debilidad del sistema: es una parte necesaria del diseño.
Top comments (0)