Blog image

Tutorial. Detector de caras con Ollama y Qwen3

December 17, 2025

¿Te acuerdas de cómo era el desarrollo web hace 6 años? jQuery todavía reinaba en muchos sitios, React Hooks acababa de nacer (o estaba a punto), y la "Inteligencia Artificial" era algo que solo vivía en servidores remotos de gigantes tecnológicos como Microsoft o Google.

En aquel entonces, creé "Search Faces", una extensión de Chrome ambiciosa. Su promesa era simple: navegabas por cualquier web, veías una foto, le dabas a un botón y te decía quién salía en ella.

Chrome extension to detect celebrities in Instagram

Chrome extension to detect celebrities in Instagram

Por debajo, aquello era un monstruo. Dependía de Azure Computer Vision API, un servicio de pago por uso que, siendo sinceros, tenía más sombras que luces. El código era el típico "Spaghetti Code" de la época: archivos de 500 líneas, var por todas partes, callbacks anidados hasta el infierno y cero modularidad.

Yo en 2018 depurando IA antes de ponerse de moda

Yo en 2018 depurando IA antes de ponerse de moda

El Problema de la Nube (y por qué huir de ella)

Después de mantener esta extensión durante años, los problemas de depender de una API de terceros se hicieron evidentes:

  1. Coste y Límites: Cada análisis costaba dinero (o gastaba créditos de la capa gratuita). Eso frenaba el desarrollo y las pruebas.
  2. Privacidad Nula: Cada foto que analizabas, ya fuera de un famoso o de tu prima en Facebook, se enviaba tal cual a los servidores de Microsoft. En 2025, esto es inaceptable para muchos.
  3. Capacidades Estancadas: La API de Azure era buena detectando a Tom Cruise, pero fallaba estrepitosamente con youtubers locales, streamers o gente "de internet". Además, han limitado algunas funcionalidades como reconocer famosos o averiguar la edad de los integrantes de las imagenes a no ser que pidamos expresamente a Microsoft que nos deje habilitarlo.

La Revolución Local: RTX 5070 Ti y Ollama

El escenario actual es radicalmente distinto. Hoy tengo en mi escritorio una NVIDIA RTX 5070 Ti. Una bestia parda con núcleos Tensor que se ríe de los modelos de IA de hace un lustro. Y lo más importante: tenemos Ollama.

Ollama ha democratizado la ejecución de LLMs en local. Así que me propuse el reto definitivo: Refactorizar Search Faces desde cero.

  • Objetivo: Eliminar Azure.
  • Nueva Arma: Ollama + Qwen3-VL.
  • Nuevo Estilo: Javascript Moderno (ESModules, Async/Await).

Anatomía Completa: Explicación y Código

Para que puedas reproducirlo, aquí tienes el código íntegro de la versión 2.0, archivo por archivo, función por función.

1. src/config/constants.js: El Cerebro Estático

En este archivo centralizamos toda la configuración estática de la extensión. El objetivo es evitar los "números mágicos" y cadenas de texto dispersas por el código, lo que facilita enormemente el mantenimiento y la posible internacionalización futura.

Aquí definimos dos constantes principales. Primero, MODES_PROMPTS, que actúa como un diccionario maestro donde las claves representan los modos de análisis disponibles ('faces', 'celebrities', 'nopor') y los valores son las instrucciones precisas (prompts en lenguaje natural) que enviaremos al modelo. Es vital que estos prompts sean muy descriptivos y soliciten explícitamente un formato JSON, ya que de ello depende que podamos procesar la respuesta con código.

Segundo, definimos DEFAULT_CONFIG. Esta constante es nuestra red de seguridad; establece los valores iniciales para cuando el usuario instala la extensión por primera vez y aún no ha configurado nada en el popup. Aquí definimos que el modelo por defecto será qwen3-vl y que asumimos que Ollama está corriendo en su puerto estándar local.

export const MODES_PROMPTS = {
  faces:
    'Detect all faces in the image. Return a JSON object with a key \'faces\' containing a list of objects, each with \'box\' (array [xmin, ymin, xmax, ymax] in 0-1000 coordinates) and \'label\' (estimated age/gender or description). Example: {"faces": [{"box": [200, 100, 400, 300], "label": "25 year old male"}]}',
  celebrities:
    "Identify any celebrities in this image. Return a JSON object with a key 'faces' containing a list, each with 'box' (array [xmin, ymin, xmax, ymax] in 0-1000 coordinates) and 'name'. If no celebrities, return empty list.",
  nopor:
    "Analyze this image for adult content. Return a JSON object with keys 'isAdultContent' (boolean) and 'adultScore' (0.0 to 1.0).",
}

export const DEFAULT_CONFIG = {
  modelName: 'qwen3-vl:8b',
  apiEndpoint: 'http://localhost:11434',
}

2. src/utils/index.js: La Caja de Herramientas

Siguiendo el principio de responsabilidad única y DRY (Don't Repeat Yourself), en este archivo agrupamos funciones puras que realizan tareas genéricas y no dependen del estado de la extensión. Esto mantiene el resto de nuestros scripts limpios y enfocados en su lógica de negocio.

blobToBase64: Esta función es el puente técnico entre el navegador y Ollama. Las APIs web modernas nos dan imágenes como objetos Blob (binarios), pero la API de Ollama espera recibir imágenes codificadas en cadenas Base64 dentro del JSON de la petición. Esta función encapsula la complejidad de usar la API FileReader antigua (que funciona con eventos) y la envuelve en una Promise moderna, devolviéndonos una cadena limpia lista para enviar.

export const blobToBase64 = (blob) =>
  new Promise((resolve, reject) => {
    const reader = new FileReader()
    reader.onloadend = () => resolve(reader.result.split(',')[1])
    reader.onerror = reject
    reader.readAsDataURL(blob)
  })

normalizeEndpoint: La experiencia de usuario importa, y los usuarios a menudo cometen pequeños errores al escribir URLs, como añadir una barra al final (http://localhost:11434/). Si no corregimos esto, al concatenar nuestros endpoints internos (ej. /api/chat), acabaríamos con una URL inválida con doble barra (//api/chat). Esta función usa una expresión regular simple para "sanitizar" la entrada del usuario, asegurando que siempre tengamos una URL base válida.

export const normalizeEndpoint = (url) => (url || DEFAULT_CONFIG.apiEndpoint).replace(/\/$/, '')

3. src/background/index.js: El Orquestador

Este script, que actúa como Service Worker en la arquitectura Manifest V3 de Chrome, es el cerebro que opera en segundo plano. Su responsabilidad principal es gestionar todas las comunicaciones de red y actuar como intermediario. Esto es necesario debido a las políticas de seguridad (CORS): las páginas web normales (y por ende los content scripts inyectados en ellas) a menudo tienen bloqueado el acceso a localhost, pero el Service Worker de una extensión tiene privilegios elevados para realizar estas peticiones.

Importaciones:

import { MODES_PROMPTS, DEFAULT_CONFIG } from '../config/constants.js'
import { blobToBase64, normalizeEndpoint } from '../utils/index.js'

fetchVisionModels: Para ofrecer una buena UX, necesitamos saber qué modelos tiene el usuario realmente instalados. Esta función consulta el endpoint /api/tags de Ollama. Pero como Ollama puede tener modelos de solo texto (como llama3), esta función realiza un filtrado inteligente. Recorre los metadatos de cada modelo buscando palabras clave como "vision" o familias específicas, y devuelve un array limpio solo con los modelos capaces de procesar imágenes.

async function fetchVisionModels(apiEndpoint) {
  const endpoint = normalizeEndpoint(apiEndpoint)
  const response = await fetch(`${endpoint}/api/tags`)
  if (!response.ok) throw new Error('Fallo al obtener modelos')
  const data = await response.json()

  // Filtramos por palabras clave conocidas de modelos visuales
  return data.models
    .filter((m) => {
      const name = m.name.toLowerCase()
      const families = m.details?.families || []
      return ['vision'].some((k) => name.includes(k) || families.includes(k))
    })
    .map((m) => m.name)
}

analyzeImageWithOllama: Esta es la función core de toda la extensión. Orquesta el flujo completo de un análisis.

  1. Descarga: Obtiene la imagen original mediante fetch.
  2. Transformación: Convierte el blob descargado a Base64.
  3. Inferencia: Construye la petición POST a Ollama, inyectando el prompt correspondiente al modo seleccionado (MODES_PROMPTS[mode]) y la imagen.
  4. Procesamiento: Recibe la respuesta. Aquí es crucial el manejo de errores: verificamos el estado HTTP (para detectar problemas de CORS) e intentamos parsear el JSON resultante. Si el modelo falla y devuelve texto plano, capturamos ese error para mostrarlo elegantemente.
async function analyzeImageWithOllama(url, mode, config) {
  const model = config.modelName || DEFAULT_CONFIG.modelName
  const endpoint = normalizeEndpoint(config.apiEndpoint)

  // 1. Descargar y Codificar
  const imgRes = await fetch(url)
  if (!imgRes.ok) throw new Error(`Error descarga: ${imgRes.statusText}`)
  const base64Image = await blobToBase64(await imgRes.blob())

  // 2. Enviar a Ollama
  const response = await fetch(`${endpoint}/api/chat`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      model,
      messages: [{ role: 'user', content: MODES_PROMPTS[mode], images: [base64Image] }],
      stream: false,
      format: 'json',
    }),
  })

  // 3. Errores y Respuesta
  if (!response.ok) throw new Error(response.status === 403 ? 'Forbidden CORS' : response.statusText)

  const content = (await response.json()).message?.content || '{}'
  try {
    return JSON.parse(content)
  } catch {
    return { raw_text: content }
  }
}

Listener de Mensajes: Aquí exponemos la funcionalidad interna al resto de componentes de la extensión. Usamos el sistema de mensajería de Chrome (chrome.runtime.onMessage). Definimos dos "rutas" o acciones: listModels (para el popup) y analyzeImage (para el content script). Un patrón importante aquí es que siempre leemos la configuración (chrome.storage.sync.get) dentro del listener, asegurando que cada petición utilice los ajustes más recientes que haya guardado el usuario, sin necesidad de reinicios.

chrome.runtime.onMessage.addListener(({ action, url, mode }, _, sendResponse) => {
  if (action === 'listModels') {
    chrome.storage.sync.get(['apiEndpoint'], ({ apiEndpoint }) =>
      fetchVisionModels(apiEndpoint)
        .then((models) => sendResponse({ success: true, models }))
        .catch((e) => sendResponse({ success: false, error: e.message })),
    )
    return true
  }

  if (action === 'analyzeImage') {
    chrome.storage.sync.get(['modelName', 'apiEndpoint'], (cfg) =>
      analyzeImageWithOllama(url, mode, cfg)
        .then((data) => sendResponse({ success: true, data }))
        .catch((e) => sendResponse({ success: false, error: e.message })),
    )
    return true
  }
})

4. src/content/index.js: Los Ojos en la Web

Este es el script que "vive" dentro de las páginas web que visitas. Es el encargado de modificar el DOM para inyectar nuestra interfaz sobre las imágenes.

Configuración Local y Reactiva: Mantenemos una variable local typeDetect con el modo actual. Lo brillante aquí es el uso de chrome.storage.onChanged. Esto nos permite escuchar cambios en la configuración en tiempo real. Si el usuario cambia de "Caras" a "Detección de Adultos" en el popup, este script se entera y actualiza su comportamiento al instante, sin obligar al usuario a recargar la página web.

let typeDetect = 'faces'
chrome.storage.sync.get(['mode'], ({ mode }) => (typeDetect = mode || 'faces'))
chrome.storage.onChanged.addListener(({ mode }) => mode && (typeDetect = mode.newValue))

createElement: Crear elementos DOM con la API estándar de JavaScript es verboso. Esta función auxiliar actúa como un "wrapper" sintáctico que nos permite crear, clasificar y añadir texto a un elemento HTML en una sola línea de código, mejorando la legibilidad de nuestras funciones de renderizado.

const createElement = (tag, cls, txt = '') => {
  const el = document.createElement(tag)
  el.className = cls
  if (txt) el.textContent = txt
  return el
}

clearResults: La limpieza es clave en modificaciones del DOM. Antes de mostrar nuevos resultados, debemos asegurarnos de que no quedan "residuos" de análisis anteriores. Esta función busca selectivamente elementos con nuestras clases CSS específicas (.faces, .face-tag) dentro del contenedor de la imagen y los elimina quirúrgicamente.

const clearResults = (parent) =>
  parent.querySelectorAll('.faces, .face-tags-container, .nopor-overlay').forEach((el) => el.remove())

renderResults: Esta función es la responsable de la visualización de los datos de la IA. Tiene dos ramas lógicas principales. Si el modo es "nopor" (filtro de contenido), inyecta un overlay informativo sobre la imagen. Si es un modo de detección (caras/celebridades), itera sobre los objetos detectados. Calcula la posición relativa (%) de las cajas delimitadoras (box) para dibujarlas sobre la imagen de forma que se adapten si la imagen se redimensiona (responsive). Si la IA no devuelve coordenadas, crea una etiqueta elegante ("chip") al pie de la imagen.

function renderResults(parent, data, isAdultMode) {
  if (isAdultMode) {
    const infoDiv = parent.querySelector('.info-overlay')
    if (infoDiv) {
      infoDiv.classList.add('nopor-overlay')
      infoDiv.innerHTML = `Likely Adult: ${data.isAdultContent ? 'SÍ' : 'NO'} (${(data.adultScore * 100).toFixed(1)}%)`
    }
    return
  }

  const faces = data.faces || []
  if (!faces.length) {
    parent
      .appendChild(createElement('div', 'face-tags-container faces'))
      .appendChild(createElement('span', 'face-tag', 'Sin Coincidencias'))
    return
  }

  const tagContainer = createElement('div', 'face-tags-container faces')
  let hasGlobalTag = false

  faces.forEach(({ box = [0, 0, 0, 0], name, label }) => {
    const txt = name || label || 'Rostro'
    const [x, y, x2, y2] = box

    if (Math.floor(x + y + x2 + y2) === 0) {
      tagContainer.appendChild(createElement('span', 'face-tag', txt))
      hasGlobalTag = true
    } else {
      const el = createElement('div', 'faces face-box')
      el.style.cssText = `left:${x / 10}%; top:${y / 10}%; width:${(x2 - x) / 10}%; height:${(y2 - y) / 10}%;`
      el.title = txt
      el.appendChild(createElement('span', '', txt))
      parent.appendChild(el)
    }
  })

  if (hasGlobalTag) parent.appendChild(tagContainer)
}

analyze: Este controlador maneja la interacción del usuario. Al hacer clic, proporciona feedback visual inmediato (un spinner de carga), bloqueando clics múltiples. Luego, delega el trabajo pesado al background script enviando un mensaje analyzeImage. Finalmente, maneja la promesa de respuesta: si hay error, alerta al usuario; si hay éxito, pasa los datos a renderResults.

function analyze(img, btn) {
  clearResults(img.parentElement)
  if (btn) btn.innerHTML = '<div class="button-spinner"></div>'

  chrome.runtime.sendMessage(
    {
      action: 'analyzeImage',
      url: img.currentSrc || img.src,
      mode: typeDetect,
    },
    (res) => {
      if (chrome.runtime.lastError || !res.success) {
        console.error(chrome.runtime.lastError || res.error)
        if (btn) btn.textContent = 'Analyze'
        alert('Error: ' + (res?.error || 'Conexión fallida'))
        return
      }
      if (btn) btn.textContent = 'Analyze'
      renderResults(img.parentElement, res.data, typeDetect === 'nopor')
    },
  )
}

injectBtn: No todas las imágenes son iguales. Esta función aplica lógica de negocio para decidir dónde inyectar el botón de análisis. Filtra imágenes demasiado pequeñas (menos de 200px) o aquellas que ya hemos procesado (evitando duplicados). Además, asegura que el contenedor padre tenga posicionamiento relativo (relative-parent) para que nuestros botones y overlays absolutos se posicionen correctamente respecto a la imagen.

const injectBtn = (img) => {
  if (img.hasAttribute('data-stop') || img.width < 200) return
  img.setAttribute('data-stop', '1')
  if (getComputedStyle(img.parentElement).position === 'static') img.parentElement.classList.add('relative-parent')

  const btn = createElement('div', 'info-overlay', 'Analyze')
  btn.onclick = (e) => {
    e.stopPropagation()
    analyze(img, btn)
  }
  img.parentElement.appendChild(btn)
}

MutationObserver: El broche de oro para una web moderna. Muchas páginas cargan contenido dinámicamente (scroll infinito, galerías AJAX). Si solo buscáramos imágenes al cargar la página (window.onload), perderíamos todo ese contenido nuevo. MutationObserver nos permite suscribirnos a cambios en el DOM. Cada vez que se añade un nodo al árbol del documento, verificamos si es una imagen y, de ser así, le inyectamos nuestras capacidades de análisis.

const observer = new MutationObserver((muts) =>
  muts.forEach((m) =>
    m.addedNodes.forEach((n) => {
      if (n.tagName === 'IMG') injectBtn(n)
      else if (n.querySelectorAll) n.querySelectorAll('img').forEach(injectBtn)
    }),
  ),
)

observer.observe(document.body, { childList: true, subtree: true })
document.querySelectorAll('img').forEach(injectBtn)

5. src/popup/index.html: El Esqueleto Visual

Finalmente, el esqueleto HTML de nuestra interfaz. A diferencia de aplicaciones web complejas, aquí optamos por la simplicidad extrema. No usamos frameworks de CSS pesados; unas pocas reglas de estilo en línea en el <head> son suficientes para una ventana de 200px. Lo más destacado es el uso del elemento <datalist id="modelList">. Esta etiqueta HTML5 nativa trabaja en conjunto con el input modelName para ofrecer autocompletado gratuito. Cuando nuestro JavaScript llena este datalist con los modelos de Ollama, el navegador ofrece automáticamente sugerencias al usuario mientras escribe, sin necesidad de librerías de UI externas.

<!DOCTYPE html>
<html>
  <head>
    <style>
      body {
        width: 200px;
        padding: 10px;
        font-family: sans-serif;
      }
      label {
        display: block;
        margin-top: 10px;
        margin-bottom: 5px;
        font-weight: bold;
      }
      input,
      select {
        width: 100%;
        box-sizing: border-box;
        padding: 5px;
      }
    </style>
  </head>
  <body>
    <label for="modelName">Ollama Model:</label>
    <input type="text" id="modelName" placeholder="qwen3-vl:8b" list="modelList" />
    <datalist id="modelList"></datalist>

    <label for="apiEndpoint">Ollama Endpoint URL:</label>
    <input type="text" id="apiEndpoint" placeholder="http://localhost:11434" />

    <label for="selectApi">Detection Mode:</label>
    <select id="selectApi">
      <option value="faces">Faces</option>
      <option value="celebrities">Celebrities</option>
      <option value="nopor">Adult Content</option>
    </select>

    <script type="text/javascript" src="index.js"></script>
  </body>
</html>

6. src/popup/index.js: La Interfaz Lógica

Este archivo controla la lógica de la ventana emergente de la extensión. Aunque parece sencillo, gestiona la persistencia de datos y la reactividad de la interfaz.

getUIElements: Para mantener el código limpio y eficiente, evitamos buscar elementos en el DOM repetidamente. Esta función factoría nos devuelve un objeto centralizado con referencias a todos los inputs y elementos clave de nuestra interfaz, permitiéndonos acceder a ellos de forma limpia (ui.modelName) en el resto del script.

function getUIElements() {
  return {
    modelName: document.getElementById('modelName'),
    apiEndpoint: document.getElementById('apiEndpoint'),
    selectApi: document.getElementById('selectApi'),
    modelList: document.getElementById('modelList'),
  }
}

saveSettings: Esta función implementa el guardado de preferencias. Toma los valores actuales de los inputs y los persiste usando chrome.storage.sync. La elección de sync sobre local es intencional: permite que la configuración del usuario viaje con él si inicia sesión en Chrome en otro dispositivo.

function saveSettings() {
  const ui = getUIElements()
  chrome.storage.sync.set({
    modelName: ui.modelName.value,
    apiEndpoint: ui.apiEndpoint.value,
    mode: ui.selectApi.value,
  })
}

loadSettings: Es la función de arranque del popup. Recupera la configuración guardada y restaura el estado de los inputs. Si no encuentra configuración previa, aplica valores por defecto sensatos (qwen3-vl:8b). Además, desencadena refreshModelList para asegurar que el autocompletado esté listo lo antes posible.

function loadSettings(ui) {
  chrome.storage.sync.get(['modelName', 'apiEndpoint', 'mode'], (result) => {
    ui.modelName.value = result.modelName || 'qwen3-vl:8b'
    ui.apiEndpoint.value = result.apiEndpoint || 'http://localhost:11434'
    if (result.mode) {
      ui.selectApi.value = result.mode
    }
    refreshModelList()
  })
}

refreshModelList: Esta función mejora drásticamente la usabilidad. Se comunica con el background script para pedir la lista real de modelos disponibles en el Ollama local del usuario. Con esa lista, construye dinámicamente las opciones de un <datalist> HTML, proporcionando un autocompletado inteligente en el input de nombre del modelo.

function refreshModelList() {
  chrome.runtime.sendMessage({ action: 'listModels' }, (response) => {
    if (response && response.success && response.models) {
      const ui = getUIElements()
      ui.modelList.innerHTML = ''
      response.models.forEach((modelName) => {
        const option = document.createElement('option')
        option.value = modelName
        ui.modelList.appendChild(option)
      })
    }
  })
}

Inicialización: En el evento window.onload, inicializamos todo el ciclo de vida. Primero cargamos los settings. Luego, establecemos una serie de "listeners" en los inputs (input, change) que llaman a saveSettings automáticamente. Esto crea una experiencia de "auto-guardado" fluida, eliminando la necesidad de un botón explícito de "Guardar cambios".

window.onload = function () {
  const ui = getUIElements()
  loadSettings(ui)
  ui.modelName.addEventListener('input', saveSettings)
  ui.apiEndpoint.addEventListener('input', saveSettings)
  ui.selectApi.addEventListener('change', saveSettings)
}

Configuración y Puesta en Marcha

Para que todo esto funcione en tu máquina, necesitas preparar el terreno. No te preocupes, es más fácil de lo que parece.

1. Hardware: El Motor

Recomendado: RTX 5070 Ti (16GB VRAM). Por qué: Inferencia en tiempo real. Usar la CPU es posible, pero cada análisis tomaría 10-20 segundos, rompiendo la experiencia de usuario.

2. Ollama: El Cerebro Local

Ollama es la pieza clave que nos permite correr estos modelos sin conocimientos de Python o Docker.

Paso 1: Descarga e Instalación

  1. Ve a https://ollama.com
  2. Descarga el instalador para tu sistema operativo (Windows, macOS o Linux).
  3. Ejecuta el instalador. Es un proceso estándar ("Siguiente, Siguiente, Instalar").
  4. Una vez instalado, abre una terminal y escribe:
    ollama --version
    
    Si ves la versión, ¡ya tienes una IA local!

Paso 2: Configuración de CORS (Crítico) Por defecto, Ollama bloquea las peticiones desde navegadores web por seguridad. Tenemos que abrirle la puerta.

En Windows (PowerShell): Cierra Ollama desde la barra de tareas (icono del sistema) y ejecútalo así:

$env:OLLAMA_ORIGINS="*"; ollama serve

En Linux/Mac:

OLLAMA_ORIGINS="*" ollama serve

3. Modelos de Visión: Elige tu "Ojo"

La extensión está configurada para Qwen3-VL, pero la belleza de Ollama es que puedes cambiar de "cerebro" en segundos.

Opción A: El Equilibrado (Recomendado)

ollama pull qwen3-vl:8b

Pros: Entiende muy bien el contexto y el español. Rápido.

Opción B: La Alternativa de Meta

ollama pull llama3.2-vision

Pros: Excepcional razonamiento lógico, aunque a veces es más verboso.

Opción C: Para portátiles (Low VRAM)

ollama pull moondream

Pros: Funciona increíblemente rápido incluso en gráficas modestas o CPUs potentes. Contras: Menos detalle en análisis complejos.

Otras alternativas:

  • llava: El clásico, muy probado.
  • minicpm-v: Muy eficiente para hardware móvil.

Conclusión

Esta migración demuestra que hoy en día no necesitamos la nube para tareas complejas de visión artificial. Con una arquitectura de código limpia y hardware moderno, tenemos privacidad y potencia ilimitada en local.

¡A disfrutar de tu propia IA privada!

CODIGO DEL TUTORIAL