De foto a información nutricional: creando una app basada en Inteligencia Artificial

FoodCheck combina un frontend en GitHub Pages, Cloudflare Workers AI y Open Food Facts para explorar estimaciones nutricionales editables a partir de fotografías.

Plato de comida con datos nutricionales

Registrar lo que comemos puede resultar tedioso: buscar alimentos, comparar marcas y ajustar cantidades. FoodCheck nació para explorar otra opción: partir de una foto y obtener una estimación nutricional editable. No requiere una cuenta en la aplicación, pero el análisis de las imágenes sí utiliza servicios externos.

Esta es la propuesta de FoodCheck, una aplicación con frontend estático en GitHub Pages que identifica posibles alimentos en una foto y calcula estimaciones nutricionales a partir de los datos de OpenFoodFacts. El procesamiento visual se realiza en un backend sin servidor: un Cloudflare Worker conectado a Workers AI, donde utilicé el modelo LLaVA.

La idea detrás de FoodCheck

El usuario abre la aplicación y arrastra una foto de la comida. El prototipo también incluye un selector de condiciones de salud, pero no ofrece asesoramiento personalizado ni valida dietas para enfermedades. Tras analizar la foto, propone alimentos y cantidades que el usuario puede corregir antes de guardar la comida del día.

El registro de comidas se guarda en el navegador. Para analizar una foto, la aplicación la envía a un Worker de Cloudflare y a Workers AI; las consultas nutricionales también pasan por el Worker para acceder a OpenFoodFacts. Por tanto, almacenamiento local no significa procesamiento exclusivamente local.

Página principal de FoodCheck

Herramientas

  • Visual Studio Code
  • HTML, Tailwind CSS y JavaScript
  • Google Fonts (Montserrat)
  • Open Food Facts (API pública para consultar productos)
  • Cloudflare Workers AI (modelo @cf/llava-hf/llava-1.5-7b-hf)
  • Despliegue: GitHub Pages (frontend) + Cloudflare Workers (proxy e IA)

Un frontend minimalista

Cuando queremos prototipar una idea rápidamente, a menudo menos es más. En el caso de FoodCheck, el frontend puede ser tan simple como un único archivo index.html. No necesitas frameworks pesados ni configuraciones complejas para empezar a experimentar.

Para conseguir una interfaz simple pero funcional que permita al usuario subir una imagen y obtener resultados de análisis visual, usamos:

  • Tailwind CSS para estilos rápidos.
  • Montserrat, una fuente elegante que mejora la estética general.
  • Un pequeño script que gestiona el flujo de interacción con el backend (en este caso, un Worker de Cloudflare).

Esta es una estructura inicial de index.html para el prototipo. Los fragmentos del artículo muestran piezas de la aplicación y presuponen otras variables, funciones y configuración; no constituyen una aplicación completa lista para copiar y ejecutar. Reflejan la implementación utilizada en este proyecto.

<!DOCTYPE html>
<html lang="es">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>FoodCheck</title>
  <link rel="preconnect" href="https://fonts.googleapis.com" />
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
  <link href="https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&display=swap" rel="stylesheet" />
  <script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>
  <script type="application/json" id="foodcheck-config">
    { "visionEndpoint": "https://TU-WORKER-NAME.tu-cuenta.workers.dev/" }
  </script>
  <style>
    body {
      background: linear-gradient(160deg, #fff6f0 0%, #fffdf8 35%, #ffffff 100%);
      color: #1f2937;
      font-family: "Montserrat", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }
    /* Other styles go here. */
  </style>
</head>
<body class="bg-gray-50 flex flex-col items-center p-6">
  <h1 class="text-2xl font-semibold mb-4">FoodCheck</h1>
  <!-- Other interface elements go here. -->
</body>
</html>

Qué ocurre después de subir la foto

Cuando el usuario arrastra una foto a FoodCheck, el proceso sigue los pasos de este diagrama:

Flujo de procesamiento de FoodCheck

1. Detección del alimento

El frontend obtiene la URL del Worker del bloque JSON de configuración incluido en la cabecera de index.html. El Worker conecta el navegador con el servicio de análisis de imágenes.

Cuando el usuario añade una imagen, la convertimos en Data URL con FileReader.readAsDataURL (formato data:image/…;base64,…):

function processFile(file) {
  const reader = new FileReader();
  reader.onload = (e) => {
    const src = e.target?.result;
    if (!src) return;
    state.image = { src, name: file.name, lastModified: file.lastModified };
    loadImagePreview(src);
  };
  reader.readAsDataURL(file);
}

Enviamos esa cadena al Worker en una petición POST con contenido JSON. El Worker prepara las instrucciones para el modelo y solicita una lista estructurada de detecciones, con un formato como este:

{
  "detections": [
    {
      "name": "manzana roja",
      "confidence": 0.92,
      "portion_estimate_grams": 120
    }
  ]
}

El prompt solicita una respuesta estructurada, pero no la garantiza. El frontend debe validar el JSON y gestionar respuestas incompletas o incorrectas antes de utilizarlo.

A su vez, la llamada al modelo es directa:

const result = await env.AI.run('@cf/llava-hf/llava-1.5-7b-hf', {
  image: Array.from(imageBytes),
  prompt: systemInstructions,
  max_tokens: 1024
});

LLaVA devuelve texto en result.description. Tras extraer de ese texto un posible bloque JSON, intentamos analizarlo y comprobamos que contenga una lista de detecciones. Este fragmento ilustra el paso de parseo:

function parseDetections(jsonString) {
  try {
    const cleaned = jsonString.replace(/\\_/g, '_');
    const json = JSON.parse(cleaned);
    return Array.isArray(json?.detections) ? json : { detections: [] };
  } catch {
    return { detections: [] };
  }
}

El frontend recibe un objeto con una lista detections. Una lista vacía indica que no se obtuvieron detecciones utilizables; no confirma que la imagen no contenga alimentos.

2. Estimación de la porción

Antes de utilizar los resultados, comprobamos los nombres y acotamos la puntuación de confianza al intervalo de 0 a 1. Esa puntuación no equivale a una probabilidad de acierto validada. Si falta una estimación de la porción, el prototipo utiliza 120 gramos como valor inicial editable:

const portionAmount = detection.portion_estimate_grams ?? 120;

El usuario puede editar la cantidad y la unidad. El prototipo incluye equivalencias fijas, como 80 g por pieza, 150 g por taza y 1 g por ml, que solo sirven como aproximaciones de ejemplo: el peso real depende del alimento, su densidad y el tamaño de la porción. Conviene introducir el peso medido en gramos.

const UNIT_CONVERSIONS = { g: 1, ml: 1, pieza: 80, taza: 150 };

function convertToGrams(amount, unit) {
  const factor = UNIT_CONVERSIONS[(unit || "g").toLowerCase()] ?? 1;
  return (Number(amount) || 0) * factor;
}

3. De nombres a nutrientes

Buscamos cada nombre en Open Food Facts y conservamos resultados que contengan un objeto de nutrientes. Esto no garantiza una ficha completa ni que el producto coincida con la comida fotografiada, especialmente si es un plato casero. El usuario debe revisar la coincidencia, las unidades y los datos disponibles. Las consultas pasan por nuestro Worker, que centraliza la comunicación y la gestión de CORS.

Para consultar el Worker, construimos una URL con parámetros como el texto de búsqueda y el número de resultados. Utilizamos la dirección definida en el bloque foodcheck-config de la cabecera del HTML, con el mismo dominio del Worker de visión:

async function queryOpenFoodFacts(query, limit = 50, options = {}) {
  if (!query || !query.trim()) return [];
  const params = new URLSearchParams({
    search_terms: query,
    search_simple: "1",
    json: "1",
    page_size: String(limit),
    lc: "es"
  });
  const externalSignal = options.signal;
  const controller = externalSignal ? null : new AbortController();
  const signal = externalSignal ?? controller.signal;
  const timeout = externalSignal ? null : setTimeout(() => controller.abort(), 7000);
  try {
    const cfg = JSON.parse(document.getElementById("foodcheck-config").textContent);
    const endpointBase = cfg.visionEndpoint.replace(/\/$/, "");
    const proxyUrl = endpointBase + "/api/nutrition?" + params.toString();
    const response = await fetch(proxyUrl, { signal });
    if (!response.ok) throw new Error("Nutrition proxy error: " + response.status);
    const data = await response.json();
    if (!Array.isArray(data.products)) return [];
    return data.products.filter((product) => product?.nutriments);
  } finally {
    if (!externalSignal && timeout) clearTimeout(timeout);
  }
}

El Worker recibe así los parámetros de búsqueda y hace la petición servidor a servidor a OpenFoodFacts:

async function handleNutritionRequest(request, env) {
  const params = new URL(request.url).searchParams;
  try {
    const offUrl = "https://world.openfoodfacts.org/cgi/search.pl?" + params.toString();
    const response = await fetch(offUrl, {
      headers: { "User-Agent": "FoodCheckApp-Proxy/1.0" }
    });
    if (!response.ok) throw new Error("Nutrition service error: " + response.status);
    const data = await response.json();
    return new Response(JSON.stringify(data), {
      status: 200,
      headers: { ...corsHeaders(), "Content-Type": "application/json" }
    });
  } catch (err) {
    return new Response(JSON.stringify({ error: "Could not query Open Food Facts" }), {
      status: 502,
      headers: { ...corsHeaders(), "Content-Type": "application/json" }
    });
  }
}

Para un producto cuyos datos estén expresados por 100 gramos, ajustamos cada nutriente a la porción estimada mediante esta fórmula. Antes hay que comprobar las unidades: algunos productos indican valores por 100 ml.

nutriente_por_porción = (nutriente_por_100g * gramos_porción) / 100

4. Visualización y registro local

La interfaz muestra las estimaciones en un gráfico de macronutrientes y una tabla, junto con una lista editable de alimentos y cantidades. También experimenté con mensajes generados mediante reglas sencillas. Los umbrales del ejemplo son decisiones del prototipo, no recomendaciones clínicas ni límites universales para una comida:

// Prototype thresholds: fibre in grams, sodium in milligrams.
// These rules are illustrative, not clinical guidance.
if (totals.fiber < 12) suggestions.push("Revisa la estimación de fibra de esta comida.");
if (totals.sodium > 800) suggestions.push("Revisa la estimación de sodio de esta comida.");

El usuario puede guardar cada comida. El registro diario suma las estimaciones de las comidas guardadas mediante localStorage. Los datos permanecen en ese navegador mientras no se borre el almacenamiento; no se sincronizan automáticamente entre dispositivos.

function persistState() {
  localStorage.setItem("foodcheck:preferences", JSON.stringify({ conditions: state.userConditions }));
  localStorage.setItem("foodcheck:dailyLog", JSON.stringify(state.dailyLog));
}

function saveCurrentMealToHistory() {
  const totals = calculateTotalsFromItems(state.foodItems);
  const entry = { timestamp: Date.now(), items: structuredClone(state.foodItems), totals };
  const day = state.currentDate;
  const dayEntry = state.dailyLog[day] ?? { items: [], totals: null };
  dayEntry.items.push(entry);
  dayEntry.totals = dayEntry.items.reduce((acc, it) => sumTotals(acc, it.totals), emptyTotals());
  state.dailyLog[day] = dayEntry;
  persistState();
}

Desplegando en GitHub Pages y Cloudflare

El despliegue es doble:

  • Frontend: repositorio estático en GitHub Pages.
  • Worker: se publica en Cloudflare con un enlace a Workers AI accesible como env.AI. Su URL se incorpora al bloque JSON de configuración del HTML.

Problemas reales y cómo los resolví

Al principio, intenté conectar FoodCheck con OpenRouter para usar modelos de visión gratuitos. Sobre el papel sonaba bien, pero en la práctica no detectaba alimentos o devolvía respuestas vacías. 

Fui abordando los problemas que aparecían:

  • El modelo no veía la imagen, así que hice que le llegaran mensajes multimodales (texto e imagen).
  • Errores de CORS, por lo que creé un Worker como proxy para gestionarlo.
  • JSON mal formado: añadí un parseo defensivo para extraer el contenido esperado, capturar errores y validar su estructura antes de usarlo.
  • Imágenes grandes o en formatos no admitidos: reduje el tamaño y exporté a JPEG mediante canvas. Para archivos HEIC, el navegador necesita poder decodificar la imagen o utilizar una conversión previa; cambiar la extensión no basta.

Pese a esos ajustes, mi integración con OpenRouter seguía sin funcionar. Sospeché que podía haber colas o tiempos de espera, pero no confirmé la causa con los registros del servicio. No podía atribuirlo con certeza a que los modelos fueran gratuitos.

Busqué una alternativa que encajara con el prototipo y probé Cloudflare Workers AI, porque ya utilizaba Workers. En mis pruebas, el modelo LLaVA respondió a la primera. La disponibilidad, los límites y los costes del servicio deben comprobarse al reproducir el proyecto.

Codex fue importante en este proceso: lo utilicé para generar funciones, depurar errores y ajustar prompts. Me ayudó a avanzar y aprender, pero también dejó una lección clara: si no explicas bien la arquitectura que necesitas, la IA puede proponer una solución que no encaje.

Necesitaba un frontend estático para GitHub Pages y un backend separado en Cloudflare. En algún momento Codex propuso un servidor Node, que GitHub Pages no ejecuta. Las variables de entorno sí pueden usarse durante la compilación, pero cualquier valor incluido en los archivos del frontend será público: los secretos deben quedarse en el Worker.

Comprendí que debía definir primero la arquitectura y mantener los prompts coherentes con ella. La IA podía acelerar el trabajo, mientras yo seguía siendo responsable de revisar sus propuestas y probar el resultado.

Reflexión final

FoodCheck no pretende sustituir a un dietista, pero demuestra algo importante: con un poco de código y una IA bien usada, podemos crear herramientas educativas cuyo tratamiento de datos debemos explicar y revisar.

Me quedo con esa posibilidad: desarrolladores curiosos pueden construir prototipos útiles para aprender, siempre que expliquen sus límites, contrasten los resultados y cuiden los datos de quienes los utilizan.

DE LA CURIOSIDAD A LA PRÁCTICA

Tu próximo paso empieza aquí.

Lleva una idea a la práctica o encuentra personas con las que seguir aprendiendo.

Recibe nuevas historias en tu lector RSS ↗

Una historia de la comunidad Saturdays.AI. Publicada originalmente el 26 nov 2025.

← Más historias de la comunidad