FLUJOS/BACK_BACK/IMAGENES/image_analyzer.py
CAPITANSITO 932e8e80db Add image pipeline and demo files from pruebas branch
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-01 00:44:37 +02:00

197 lines
7.9 KiB
Python

"""
image_analyzer.py
-----------------
Analiza imágenes usando un VLM via ollama y extrae:
- tema, subtema
- keywords (lista de palabras clave)
- descripción
- entidades (personas, organizaciones, lugares)
Modelos recomendados en ollama para visión:
- llava:13b (bueno, ligero)
- qwen2-vl:7b (muy bueno para keywords)
- minicpm-v:8b (rápido y preciso)
- llava-llama3:8b (balance velocidad/calidad)
Para usar Qwen3.5 GGUF (texto), primero genera caption con un VLM
y luego pasa el texto a Qwen para enriquecer keywords — ver pipeline_pruebas.py
Uso:
analyzer = ImageAnalyzer(model="qwen2-vl:7b")
result = analyzer.analyze("foto.jpg")
results = analyzer.analyze_folder("./mis_imagenes/")
"""
import base64
import json
import os
import re
from datetime import datetime
from pathlib import Path
import requests
# ── Configuración ──────────────────────────────────────────────────────────────
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
DEFAULT_MODEL = os.getenv("VISION_MODEL", "qwen2-vl:7b")
SUPPORTED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp"}
KEYWORD_PROMPT = """Analiza esta imagen en detalle.
Devuelve ÚNICAMENTE un objeto JSON válido con esta estructura exacta, sin texto adicional:
{
"tema": "tema principal de la imagen (1-3 palabras en español)",
"subtema": "subtema específico (1-4 palabras en español)",
"keywords": ["palabra1", "palabra2", "palabra3"],
"descripcion": "descripción breve y objetiva de lo que muestra la imagen (1-2 frases)",
"entidades": ["nombre_propio1", "organizacion1", "lugar1"],
"idioma_detectado": "es/en/fr/..."
}
Requisitos:
- keywords: entre 8 y 15 palabras clave relevantes, en minúsculas
- entidades: solo si son claramente visibles/identificables, puede estar vacío []
- todo el contenido en español salvo entidades propias
- SOLO el JSON, sin markdown ni explicaciones"""
# ── Clase principal ─────────────────────────────────────────────────────────────
class ImageAnalyzer:
def __init__(self, model: str = DEFAULT_MODEL, ollama_url: str = OLLAMA_URL):
self.model = model
self.ollama_url = ollama_url.rstrip("/")
# ── Helpers ────────────────────────────────────────────────────────────────
@staticmethod
def encode_image(image_path: str) -> str:
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def _check_ollama(self) -> bool:
try:
r = requests.get(f"{self.ollama_url}/api/tags", timeout=5)
return r.status_code == 200
except Exception:
return False
def _parse_json_response(self, raw: str) -> dict:
"""Extrae JSON del response aunque venga con texto alrededor."""
raw = raw.strip()
# Buscar bloque JSON entre llaves
match = re.search(r'\{[\s\S]*\}', raw)
if match:
return json.loads(match.group())
raise ValueError(f"No se encontró JSON válido en el response:\n{raw[:300]}")
# ── Análisis de una imagen ─────────────────────────────────────────────────
def analyze(self, image_path: str, extra_context: str = "") -> dict:
"""
Analiza una imagen y devuelve dict con keywords y metadata.
Args:
image_path: ruta a la imagen
extra_context: contexto adicional para el prompt (ej: "esta imagen es de un periódico sobre guerras")
Returns:
dict con: archivo, tema, subtema, texto, keywords, entidades,
source_type, fecha, image_path
"""
if not os.path.exists(image_path):
raise FileNotFoundError(f"Imagen no encontrada: {image_path}")
prompt = KEYWORD_PROMPT
if extra_context:
prompt = f"Contexto adicional: {extra_context}\n\n" + prompt
img_b64 = self.encode_image(image_path)
payload = {
"model": self.model,
"prompt": prompt,
"images": [img_b64],
"stream": False,
"format": "json",
"options": {
"temperature": 0.1, # baja temperatura = más determinista
"num_predict": 512,
}
}
print(f" → Enviando a ollama ({self.model}): {Path(image_path).name}")
response = requests.post(
f"{self.ollama_url}/api/generate",
json=payload,
timeout=180
)
response.raise_for_status()
raw_response = response.json().get("response", "")
parsed = self._parse_json_response(raw_response)
return {
"archivo": Path(image_path).name,
"image_path": str(Path(image_path).resolve()),
"tema": parsed.get("tema", "sin_clasificar").lower(),
"subtema": parsed.get("subtema", "").lower(),
"texto": parsed.get("descripcion", ""),
"keywords": [k.lower().strip() for k in parsed.get("keywords", [])],
"entidades": parsed.get("entidades", []),
"idioma": parsed.get("idioma_detectado", "es"),
"source_type": "imagen",
"fecha": datetime.now().strftime("%Y-%m-%d"),
"modelo_usado": self.model,
}
# ── Análisis de una carpeta ────────────────────────────────────────────────
def analyze_folder(self, folder_path: str, extra_context: str = "") -> list[dict]:
"""
Analiza todas las imágenes de una carpeta.
Returns:
Lista de resultados, incluye errores como dicts con campo 'error'
"""
folder = Path(folder_path)
if not folder.exists():
raise FileNotFoundError(f"Carpeta no encontrada: {folder_path}")
images = [p for p in folder.iterdir() if p.suffix.lower() in SUPPORTED_EXTENSIONS]
print(f"\n[ImageAnalyzer] {len(images)} imágenes encontradas en {folder_path}")
if not self._check_ollama():
print(f" ⚠ ollama no responde en {self.ollama_url}")
print(" Instala ollama: https://ollama.ai")
print(f" Luego: ollama pull {self.model}")
return []
results = []
for i, img_path in enumerate(images, 1):
print(f" [{i}/{len(images)}] {img_path.name}")
try:
result = self.analyze(str(img_path), extra_context)
results.append(result)
print(f" tema={result['tema']} | keywords={result['keywords'][:4]}...")
except Exception as e:
print(f" ERROR: {e}")
results.append({
"archivo": img_path.name,
"error": str(e),
"source_type": "imagen",
"fecha": datetime.now().strftime("%Y-%m-%d"),
})
print(f"\n[ImageAnalyzer] Completado: {len([r for r in results if 'error' not in r])}/{len(images)} OK\n")
return results
# ── Guardar resultados a JSON ──────────────────────────────────────────────
@staticmethod
def save_json(results: list[dict], output_path: str):
with open(output_path, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"[ImageAnalyzer] Guardado: {output_path} ({len(results)} registros)")