Spaces:
Sleeping
Sleeping
har1zarD
commited on
Commit
·
9ccc31e
1
Parent(s):
213ba68
readme
Browse files- Dockerfile +51 -19
- app.py +544 -80
- requirements.txt +3 -0
Dockerfile
CHANGED
|
@@ -1,14 +1,22 @@
|
|
| 1 |
-
#
|
|
|
|
|
|
|
|
|
|
| 2 |
FROM python:3.11-slim
|
| 3 |
|
| 4 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
RUN useradd -m -u 1000 user
|
| 6 |
|
| 7 |
# Set working directory
|
| 8 |
WORKDIR /app
|
| 9 |
|
| 10 |
-
# Install system dependencies for
|
| 11 |
-
RUN apt-get update && apt-get install -y \
|
| 12 |
gcc \
|
| 13 |
g++ \
|
| 14 |
libglib2.0-0 \
|
|
@@ -19,30 +27,48 @@ RUN apt-get update && apt-get install -y \
|
|
| 19 |
libgl1-mesa-dev \
|
| 20 |
libglib2.0-dev \
|
| 21 |
curl \
|
| 22 |
-
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
| 23 |
|
| 24 |
-
# Copy requirements first (
|
| 25 |
COPY --chown=user:user requirements.txt .
|
| 26 |
|
| 27 |
-
# Install
|
| 28 |
-
|
|
|
|
|
|
|
| 29 |
|
| 30 |
-
# Install remaining
|
| 31 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 32 |
|
| 33 |
-
# Copy application code
|
| 34 |
-
COPY --chown=user:user app.py
|
| 35 |
|
| 36 |
-
# Create cache
|
| 37 |
-
RUN mkdir -p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
# Switch to non-root user
|
| 40 |
USER user
|
| 41 |
|
| 42 |
-
#
|
|
|
|
|
|
|
|
|
|
| 43 |
ENV PYTHONUNBUFFERED=1
|
|
|
|
|
|
|
|
|
|
| 44 |
ENV PORT=7860
|
|
|
|
|
|
|
| 45 |
ENV HOME=/home/user
|
|
|
|
|
|
|
| 46 |
ENV HF_HOME=/tmp/huggingface
|
| 47 |
ENV TRANSFORMERS_CACHE=/tmp/transformers
|
| 48 |
ENV XDG_CACHE_HOME=/tmp
|
|
@@ -50,17 +76,23 @@ ENV TORCH_HOME=/tmp/torch
|
|
| 50 |
ENV HF_HUB_DISABLE_TELEMETRY=1
|
| 51 |
ENV HF_HUB_ENABLE_HF_TRANSFER=0
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
# Performance optimizations
|
| 54 |
ENV TOKENIZERS_PARALLELISM=false
|
| 55 |
ENV OMP_NUM_THREADS=2
|
| 56 |
ENV MKL_NUM_THREADS=2
|
| 57 |
|
| 58 |
-
# Expose port
|
| 59 |
EXPOSE 7860
|
| 60 |
|
| 61 |
-
# Health check
|
| 62 |
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=
|
| 63 |
CMD curl -f http://localhost:7860/health || exit 1
|
| 64 |
|
| 65 |
-
# Run the
|
| 66 |
CMD ["python", "app.py"]
|
|
|
|
| 1 |
+
# ============================================================
|
| 2 |
+
# 🍽️ Trainera Food Recognition API
|
| 3 |
+
# Production-Ready Multilingual AI Food Recognition
|
| 4 |
+
# ============================================================
|
| 5 |
FROM python:3.11-slim
|
| 6 |
|
| 7 |
+
# Metadata
|
| 8 |
+
LABEL maintainer="Trainera Team"
|
| 9 |
+
LABEL description="AI Food Recognition API with OpenAI translations (101+ food categories)"
|
| 10 |
+
LABEL version="2.0.0"
|
| 11 |
+
|
| 12 |
+
# Create non-root user for security (HF Spaces requirement)
|
| 13 |
RUN useradd -m -u 1000 user
|
| 14 |
|
| 15 |
# Set working directory
|
| 16 |
WORKDIR /app
|
| 17 |
|
| 18 |
+
# Install system dependencies for ML and image processing
|
| 19 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 20 |
gcc \
|
| 21 |
g++ \
|
| 22 |
libglib2.0-0 \
|
|
|
|
| 27 |
libgl1-mesa-dev \
|
| 28 |
libglib2.0-dev \
|
| 29 |
curl \
|
| 30 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 31 |
+
&& apt-get clean
|
| 32 |
|
| 33 |
+
# Copy requirements first (Docker layer caching optimization)
|
| 34 |
COPY --chown=user:user requirements.txt .
|
| 35 |
|
| 36 |
+
# Install Python dependencies
|
| 37 |
+
# Step 1: Install NumPy 1.x first (transformers compatibility)
|
| 38 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 39 |
+
pip install --no-cache-dir "numpy>=1.24.0,<2.0.0"
|
| 40 |
|
| 41 |
+
# Step 2: Install remaining dependencies
|
| 42 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 43 |
|
| 44 |
+
# Copy application code
|
| 45 |
+
COPY --chown=user:user app.py .
|
| 46 |
|
| 47 |
+
# Create cache directories with correct permissions
|
| 48 |
+
RUN mkdir -p \
|
| 49 |
+
/home/user/.cache \
|
| 50 |
+
/tmp/transformers \
|
| 51 |
+
/tmp/huggingface \
|
| 52 |
+
/tmp/torch \
|
| 53 |
+
&& chown -R user:user /home/user/.cache /tmp/transformers /tmp/huggingface /tmp/torch
|
| 54 |
|
| 55 |
+
# Switch to non-root user (security best practice)
|
| 56 |
USER user
|
| 57 |
|
| 58 |
+
# Environment Variables
|
| 59 |
+
# ============================================================
|
| 60 |
+
|
| 61 |
+
# Python configuration
|
| 62 |
ENV PYTHONUNBUFFERED=1
|
| 63 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 64 |
+
|
| 65 |
+
# Port configuration (7860 = HF Spaces standard)
|
| 66 |
ENV PORT=7860
|
| 67 |
+
|
| 68 |
+
# User home
|
| 69 |
ENV HOME=/home/user
|
| 70 |
+
|
| 71 |
+
# Hugging Face cache directories
|
| 72 |
ENV HF_HOME=/tmp/huggingface
|
| 73 |
ENV TRANSFORMERS_CACHE=/tmp/transformers
|
| 74 |
ENV XDG_CACHE_HOME=/tmp
|
|
|
|
| 76 |
ENV HF_HUB_DISABLE_TELEMETRY=1
|
| 77 |
ENV HF_HUB_ENABLE_HF_TRANSFER=0
|
| 78 |
|
| 79 |
+
# OpenAI API Key (set via HF Spaces secrets or docker run -e)
|
| 80 |
+
ENV OPENAI_API_KEY=${OPENAI_API_KEY:-}
|
| 81 |
+
|
| 82 |
+
# USDA API Keys (optional - defaults to DEMO_KEY)
|
| 83 |
+
ENV USDA_API_KEY=${USDA_API_KEY:-DEMO_KEY}
|
| 84 |
+
|
| 85 |
# Performance optimizations
|
| 86 |
ENV TOKENIZERS_PARALLELISM=false
|
| 87 |
ENV OMP_NUM_THREADS=2
|
| 88 |
ENV MKL_NUM_THREADS=2
|
| 89 |
|
| 90 |
+
# Expose port
|
| 91 |
EXPOSE 7860
|
| 92 |
|
| 93 |
+
# Health check (monitors API health every 30s)
|
| 94 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=90s --retries=3 \
|
| 95 |
CMD curl -f http://localhost:7860/health || exit 1
|
| 96 |
|
| 97 |
+
# Run the application
|
| 98 |
CMD ["python", "app.py"]
|
app.py
CHANGED
|
@@ -19,13 +19,25 @@ import aiohttp
|
|
| 19 |
import re
|
| 20 |
from typing import Dict, Any, List, Optional
|
| 21 |
from io import BytesIO
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
import torch
|
| 24 |
import torch.nn.functional as F
|
| 25 |
from PIL import Image, ImageEnhance
|
| 26 |
import numpy as np
|
| 27 |
|
| 28 |
-
from fastapi import FastAPI, File, UploadFile, HTTPException, Request
|
| 29 |
from fastapi.middleware.cors import CORSMiddleware
|
| 30 |
from fastapi.responses import JSONResponse
|
| 31 |
import uvicorn
|
|
@@ -33,11 +45,18 @@ import uvicorn
|
|
| 33 |
from transformers import AutoImageProcessor, AutoModelForImageClassification
|
| 34 |
from contextlib import asynccontextmanager
|
| 35 |
|
|
|
|
|
|
|
|
|
|
| 36 |
# ==================== CONFIGURATION ====================
|
| 37 |
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
| 38 |
MAX_IMAGE_SIZE = 512
|
| 39 |
ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp"]
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
# ==================== MULTI-MODEL FOOD RECOGNITION ====================
|
| 42 |
FOOD_MODELS = {
|
| 43 |
# Primary specialize food models
|
|
@@ -86,9 +105,9 @@ PRIMARY_MODEL = "food101"
|
|
| 86 |
COMPREHENSIVE_FOOD_CATEGORIES = {
|
| 87 |
# Food-101 categories
|
| 88 |
"pizza", "hamburger", "sushi", "ice_cream", "french_fries", "chicken_wings",
|
| 89 |
-
"chocolate_cake", "caesar_salad", "steak", "tacos", "pancakes", "lasagna",
|
| 90 |
-
"apple_pie", "chicken_curry", "pad_thai", "ramen", "waffles", "donuts",
|
| 91 |
-
"cheesecake", "fish_and_chips", "fried_rice", "greek_salad", "guacamole",
|
| 92 |
|
| 93 |
# Balkanska/Srpska tradicionalna jela
|
| 94 |
"cevapi", "cevapcici", "burek", "pljeskavica", "sarma", "klepe", "dolma",
|
|
@@ -365,10 +384,19 @@ def clean_food_name_for_search(raw_name: str) -> str:
|
|
| 365 |
"""Smart cleaning of Food-101 names for better API searches."""
|
| 366 |
# Remove underscores and replace with spaces
|
| 367 |
cleaned = raw_name.replace("_", " ")
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
# Remove common Food-101 artifacts
|
| 370 |
cleaned = re.sub(r'\b(and|with|the|a)\b', ' ', cleaned, flags=re.IGNORECASE)
|
| 371 |
-
|
| 372 |
# Handle specific Food-101 patterns
|
| 373 |
replacements = {
|
| 374 |
"cup cakes": "cupcakes",
|
|
@@ -378,15 +406,16 @@ def clean_food_name_for_search(raw_name: str) -> str:
|
|
| 378 |
"shrimp and grits": "shrimp grits",
|
| 379 |
"macaroni and cheese": "mac and cheese"
|
| 380 |
}
|
| 381 |
-
|
| 382 |
for old, new in replacements.items():
|
| 383 |
if old in cleaned.lower():
|
| 384 |
cleaned = new
|
| 385 |
break
|
| 386 |
-
|
| 387 |
-
# Clean whitespace
|
| 388 |
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
| 389 |
-
|
|
|
|
| 390 |
return cleaned
|
| 391 |
|
| 392 |
async def search_openfoodfacts_nutrition(food_name: str) -> Optional[Dict[str, Any]]:
|
|
@@ -472,9 +501,9 @@ async def get_nutrition_from_apis(food_name: str) -> Dict[str, Any]:
|
|
| 472 |
"""Get nutrition data from multiple FREE databases with comprehensive fallback."""
|
| 473 |
# Clean the Food-101 name for better searches
|
| 474 |
cleaned_name = clean_food_name_for_search(food_name)
|
| 475 |
-
|
| 476 |
logger.info(f"🔍 Searching nutrition for: '{food_name}' → '{cleaned_name}'")
|
| 477 |
-
|
| 478 |
# Try APIs in order: Free/Unlimited first, then limited APIs
|
| 479 |
nutrition_sources = [
|
| 480 |
("OpenFoodFacts", search_openfoodfacts_nutrition), # FREE, 2M+ products
|
|
@@ -483,23 +512,238 @@ async def get_nutrition_from_apis(food_name: str) -> Dict[str, Any]:
|
|
| 483 |
("Edamam", search_edamam_nutrition), # 1000/month limit
|
| 484 |
("Spoonacular", search_spoonacular_nutrition) # 150/day limit
|
| 485 |
]
|
| 486 |
-
|
|
|
|
| 487 |
for source_name, search_func in nutrition_sources:
|
| 488 |
try:
|
| 489 |
nutrition_data = await search_func(cleaned_name)
|
| 490 |
if nutrition_data and nutrition_data.get("calories", 0) > 0:
|
| 491 |
nutrition_data["source"] = source_name
|
|
|
|
| 492 |
return nutrition_data
|
| 493 |
except Exception as e:
|
| 494 |
-
logger.warning(f"⚠️ {source_name} search failed: {e}")
|
| 495 |
continue
|
| 496 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 497 |
# All APIs failed, return default values
|
| 498 |
-
logger.warning(f"🚨 No nutrition data found for '{
|
| 499 |
default_nutrition = DEFAULT_NUTRITION.copy()
|
| 500 |
default_nutrition["source"] = "Default (APIs unavailable)"
|
| 501 |
return default_nutrition
|
| 502 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
# ==================== MULTI-MODEL FOOD RECOGNIZER ====================
|
| 504 |
class MultiModelFoodRecognizer:
|
| 505 |
"""Production-ready multi-model ensemble for comprehensive food recognition."""
|
|
@@ -657,84 +901,186 @@ class MultiModelFoodRecognizer:
|
|
| 657 |
"""Main predict method - uses ensemble if available, fallback to primary."""
|
| 658 |
return self.predict_ensemble(image, top_k)
|
| 659 |
|
| 660 |
-
def predict_ensemble(self, image: Image.Image, top_k: int =
|
| 661 |
-
"""Ensemble prediction using all available models."""
|
| 662 |
if not self.is_loaded:
|
| 663 |
raise RuntimeError("Models not loaded")
|
| 664 |
-
|
| 665 |
all_predictions = []
|
| 666 |
model_results = {}
|
| 667 |
-
|
| 668 |
-
# Get predictions from all models
|
|
|
|
|
|
|
| 669 |
for model_key in self.available_models:
|
| 670 |
-
predictions = self._predict_with_model(image, model_key,
|
| 671 |
if predictions:
|
| 672 |
model_results[model_key] = predictions
|
| 673 |
all_predictions.extend(predictions)
|
| 674 |
-
|
| 675 |
if not all_predictions:
|
| 676 |
raise RuntimeError("No models produced valid predictions")
|
| 677 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
# Ensemble voting: weight by model priority and confidence
|
| 679 |
food_scores = {}
|
|
|
|
|
|
|
| 680 |
for pred in all_predictions:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 681 |
model_key = pred["model"]
|
| 682 |
priority_weight = 1.0 / FOOD_MODELS[model_key]["priority"] # Higher priority = lower number = higher weight
|
| 683 |
confidence_weight = pred["confidence"]
|
| 684 |
-
|
| 685 |
-
#
|
| 686 |
-
|
| 687 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 688 |
food_name = pred["raw_label"]
|
| 689 |
if food_name not in food_scores:
|
| 690 |
food_scores[food_name] = {
|
| 691 |
"total_score": 0,
|
| 692 |
"count": 0,
|
| 693 |
"best_prediction": pred,
|
| 694 |
-
"models": []
|
|
|
|
| 695 |
}
|
| 696 |
-
|
| 697 |
food_scores[food_name]["total_score"] += combined_score
|
| 698 |
food_scores[food_name]["count"] += 1
|
| 699 |
food_scores[food_name]["models"].append(model_key)
|
| 700 |
-
|
| 701 |
# Keep the prediction with highest confidence as representative
|
| 702 |
if pred["confidence"] > food_scores[food_name]["best_prediction"]["confidence"]:
|
| 703 |
food_scores[food_name]["best_prediction"] = pred
|
| 704 |
-
|
|
|
|
|
|
|
|
|
|
| 705 |
# Sort by ensemble score
|
| 706 |
sorted_foods = sorted(
|
| 707 |
-
food_scores.items(),
|
| 708 |
-
key=lambda x: x[1]["total_score"],
|
| 709 |
reverse=True
|
| 710 |
)
|
| 711 |
-
|
| 712 |
-
# Format final results
|
| 713 |
final_predictions = []
|
| 714 |
-
for food_name, data in sorted_foods[:top_k]:
|
| 715 |
pred = data["best_prediction"].copy()
|
| 716 |
pred["ensemble_score"] = data["total_score"]
|
| 717 |
pred["model_count"] = data["count"]
|
| 718 |
pred["contributing_models"] = data["models"]
|
|
|
|
| 719 |
final_predictions.append(pred)
|
| 720 |
-
|
| 721 |
-
#
|
| 722 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
"label": "Unknown Food",
|
| 724 |
"raw_label": "unknown",
|
| 725 |
"confidence": 0.0,
|
| 726 |
"ensemble_score": 0.0,
|
| 727 |
"model_count": 0,
|
| 728 |
-
"contributing_models": []
|
|
|
|
| 729 |
}
|
| 730 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 731 |
return {
|
| 732 |
"success": True,
|
| 733 |
"label": primary["label"],
|
| 734 |
"confidence": primary["confidence"],
|
| 735 |
"primary_label": primary["raw_label"],
|
| 736 |
"ensemble_score": primary.get("ensemble_score", 0),
|
| 737 |
-
"alternatives":
|
| 738 |
"model_results": model_results,
|
| 739 |
"system_info": {
|
| 740 |
"available_models": self.available_models,
|
|
@@ -756,8 +1102,9 @@ async def lifespan(app: FastAPI):
|
|
| 756 |
logger.info(f"🖥️ Device: {device.upper()}")
|
| 757 |
logger.info(f"📊 Models: {len(recognizer.available_models)} active models")
|
| 758 |
logger.info(f"🎯 Total Food Categories: {sum(FOOD_MODELS[m]['classes'] for m in recognizer.available_models)}")
|
|
|
|
| 759 |
logger.info("=" * 60)
|
| 760 |
-
|
| 761 |
yield
|
| 762 |
|
| 763 |
# Shutdown
|
|
@@ -780,6 +1127,17 @@ logger.info("=" * 60)
|
|
| 780 |
device = select_device()
|
| 781 |
recognizer = MultiModelFoodRecognizer(device)
|
| 782 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 783 |
# Create FastAPI app
|
| 784 |
app = FastAPI(
|
| 785 |
title="AI Food Recognition API",
|
|
@@ -867,43 +1225,149 @@ def health_check():
|
|
| 867 |
}
|
| 868 |
|
| 869 |
@app.post("/api/nutrition/analyze-food")
|
| 870 |
-
async def analyze_food_nutrition(file: UploadFile = File(
|
| 871 |
"""
|
| 872 |
-
Analyze food image for Next.js frontend.
|
| 873 |
-
|
| 874 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
"""
|
| 876 |
-
logger.info(f"🍽️ Nutrition analysis request: {file.filename}")
|
| 877 |
-
|
| 878 |
try:
|
| 879 |
-
#
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
#
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
|
| 893 |
-
|
| 894 |
-
|
| 895 |
-
|
| 896 |
-
"
|
| 897 |
-
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
|
| 901 |
-
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
|
| 906 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 907 |
except HTTPException:
|
| 908 |
raise
|
| 909 |
except Exception as e:
|
|
@@ -923,8 +1387,8 @@ async def analyze_food_spaces(file: UploadFile = File(...)):
|
|
| 923 |
# Validate and process image
|
| 924 |
image = await validate_and_read_image(file)
|
| 925 |
|
| 926 |
-
# Step 1: AI Model Prediction
|
| 927 |
-
results = recognizer.predict(image, top_k=
|
| 928 |
|
| 929 |
# Step 2: API Nutrition Lookup
|
| 930 |
nutrition_data = await get_nutrition_from_apis(results["primary_label"])
|
|
|
|
| 19 |
import re
|
| 20 |
from typing import Dict, Any, List, Optional
|
| 21 |
from io import BytesIO
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
# Load .env file if exists
|
| 25 |
+
try:
|
| 26 |
+
from dotenv import load_dotenv
|
| 27 |
+
env_path = Path(__file__).parent / '.env'
|
| 28 |
+
load_dotenv(dotenv_path=env_path)
|
| 29 |
+
logging.info(f"✅ Loaded .env from {env_path}")
|
| 30 |
+
except ImportError:
|
| 31 |
+
logging.warning("⚠️ python-dotenv not installed, using system environment variables")
|
| 32 |
+
except Exception as e:
|
| 33 |
+
logging.warning(f"⚠️ Could not load .env: {e}")
|
| 34 |
|
| 35 |
import torch
|
| 36 |
import torch.nn.functional as F
|
| 37 |
from PIL import Image, ImageEnhance
|
| 38 |
import numpy as np
|
| 39 |
|
| 40 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException, Request, Form
|
| 41 |
from fastapi.middleware.cors import CORSMiddleware
|
| 42 |
from fastapi.responses import JSONResponse
|
| 43 |
import uvicorn
|
|
|
|
| 45 |
from transformers import AutoImageProcessor, AutoModelForImageClassification
|
| 46 |
from contextlib import asynccontextmanager
|
| 47 |
|
| 48 |
+
# OpenAI for translations
|
| 49 |
+
from openai import AsyncOpenAI
|
| 50 |
+
|
| 51 |
# ==================== CONFIGURATION ====================
|
| 52 |
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
| 53 |
MAX_IMAGE_SIZE = 512
|
| 54 |
ALLOWED_TYPES = ["image/jpeg", "image/jpg", "image/png", "image/webp"]
|
| 55 |
|
| 56 |
+
# OpenAI Configuration (will be initialized after logger is set up)
|
| 57 |
+
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
|
| 58 |
+
openai_client = None # Will be initialized in lifespan startup
|
| 59 |
+
|
| 60 |
# ==================== MULTI-MODEL FOOD RECOGNITION ====================
|
| 61 |
FOOD_MODELS = {
|
| 62 |
# Primary specialize food models
|
|
|
|
| 105 |
COMPREHENSIVE_FOOD_CATEGORIES = {
|
| 106 |
# Food-101 categories
|
| 107 |
"pizza", "hamburger", "sushi", "ice_cream", "french_fries", "chicken_wings",
|
| 108 |
+
"chocolate_cake", "caesar_salad", "steak", "tacos", "pancakes", "pancake", "lasagna",
|
| 109 |
+
"apple_pie", "chicken_curry", "pad_thai", "ramen", "waffles", "waffle", "donuts",
|
| 110 |
+
"cheesecake", "fish_and_chips", "fried_rice", "greek_salad", "guacamole", "crepe", "crepes",
|
| 111 |
|
| 112 |
# Balkanska/Srpska tradicionalna jela
|
| 113 |
"cevapi", "cevapcici", "burek", "pljeskavica", "sarma", "klepe", "dolma",
|
|
|
|
| 384 |
"""Smart cleaning of Food-101 names for better API searches."""
|
| 385 |
# Remove underscores and replace with spaces
|
| 386 |
cleaned = raw_name.replace("_", " ")
|
| 387 |
+
|
| 388 |
+
# Handle comma-separated names - take the first part (usually English name)
|
| 389 |
+
# Example: "Pineapple, Ananas" → "Pineapple"
|
| 390 |
+
if "," in cleaned:
|
| 391 |
+
parts = cleaned.split(",")
|
| 392 |
+
# Try to detect which part is English (usually the first one)
|
| 393 |
+
# Keep the part that's more likely to be in nutrition databases
|
| 394 |
+
cleaned = parts[0].strip()
|
| 395 |
+
logger.info(f"🧹 Cleaned comma-separated name: '{raw_name}' → '{cleaned}'")
|
| 396 |
+
|
| 397 |
# Remove common Food-101 artifacts
|
| 398 |
cleaned = re.sub(r'\b(and|with|the|a)\b', ' ', cleaned, flags=re.IGNORECASE)
|
| 399 |
+
|
| 400 |
# Handle specific Food-101 patterns
|
| 401 |
replacements = {
|
| 402 |
"cup cakes": "cupcakes",
|
|
|
|
| 406 |
"shrimp and grits": "shrimp grits",
|
| 407 |
"macaroni and cheese": "mac and cheese"
|
| 408 |
}
|
| 409 |
+
|
| 410 |
for old, new in replacements.items():
|
| 411 |
if old in cleaned.lower():
|
| 412 |
cleaned = new
|
| 413 |
break
|
| 414 |
+
|
| 415 |
+
# Clean whitespace and extra punctuation
|
| 416 |
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
| 417 |
+
cleaned = re.sub(r'[^\w\s-]', '', cleaned) # Remove special chars except hyphens
|
| 418 |
+
|
| 419 |
return cleaned
|
| 420 |
|
| 421 |
async def search_openfoodfacts_nutrition(food_name: str) -> Optional[Dict[str, Any]]:
|
|
|
|
| 501 |
"""Get nutrition data from multiple FREE databases with comprehensive fallback."""
|
| 502 |
# Clean the Food-101 name for better searches
|
| 503 |
cleaned_name = clean_food_name_for_search(food_name)
|
| 504 |
+
|
| 505 |
logger.info(f"🔍 Searching nutrition for: '{food_name}' → '{cleaned_name}'")
|
| 506 |
+
|
| 507 |
# Try APIs in order: Free/Unlimited first, then limited APIs
|
| 508 |
nutrition_sources = [
|
| 509 |
("OpenFoodFacts", search_openfoodfacts_nutrition), # FREE, 2M+ products
|
|
|
|
| 512 |
("Edamam", search_edamam_nutrition), # 1000/month limit
|
| 513 |
("Spoonacular", search_spoonacular_nutrition) # 150/day limit
|
| 514 |
]
|
| 515 |
+
|
| 516 |
+
# First attempt with cleaned name
|
| 517 |
for source_name, search_func in nutrition_sources:
|
| 518 |
try:
|
| 519 |
nutrition_data = await search_func(cleaned_name)
|
| 520 |
if nutrition_data and nutrition_data.get("calories", 0) > 0:
|
| 521 |
nutrition_data["source"] = source_name
|
| 522 |
+
logger.info(f"✅ Found nutrition data from {source_name} for '{cleaned_name}'")
|
| 523 |
return nutrition_data
|
| 524 |
except Exception as e:
|
| 525 |
+
logger.warning(f"⚠️ {source_name} search failed for '{cleaned_name}': {e}")
|
| 526 |
continue
|
| 527 |
+
|
| 528 |
+
# If cleaned name failed and it's different from original, try original name too
|
| 529 |
+
if cleaned_name.lower() != food_name.lower():
|
| 530 |
+
logger.info(f"🔄 Retrying with original name: '{food_name}'")
|
| 531 |
+
for source_name, search_func in nutrition_sources:
|
| 532 |
+
try:
|
| 533 |
+
nutrition_data = await search_func(food_name)
|
| 534 |
+
if nutrition_data and nutrition_data.get("calories", 0) > 0:
|
| 535 |
+
nutrition_data["source"] = source_name
|
| 536 |
+
logger.info(f"✅ Found nutrition data from {source_name} for original '{food_name}'")
|
| 537 |
+
return nutrition_data
|
| 538 |
+
except Exception as e:
|
| 539 |
+
logger.warning(f"⚠️ {source_name} search failed for original '{food_name}': {e}")
|
| 540 |
+
continue
|
| 541 |
+
|
| 542 |
+
# Try with just the first word as last resort (e.g., "pineapple juice" → "pineapple")
|
| 543 |
+
words = cleaned_name.split()
|
| 544 |
+
if len(words) > 1:
|
| 545 |
+
first_word = words[0]
|
| 546 |
+
logger.info(f"🔄 Last resort: trying first word only: '{first_word}'")
|
| 547 |
+
for source_name, search_func in nutrition_sources:
|
| 548 |
+
try:
|
| 549 |
+
nutrition_data = await search_func(first_word)
|
| 550 |
+
if nutrition_data and nutrition_data.get("calories", 0) > 0:
|
| 551 |
+
nutrition_data["source"] = f"{source_name} (matched: {first_word})"
|
| 552 |
+
logger.info(f"✅ Found nutrition data from {source_name} for '{first_word}'")
|
| 553 |
+
return nutrition_data
|
| 554 |
+
except Exception as e:
|
| 555 |
+
logger.warning(f"⚠️ {source_name} search failed for '{first_word}': {e}")
|
| 556 |
+
continue
|
| 557 |
+
|
| 558 |
# All APIs failed, return default values
|
| 559 |
+
logger.warning(f"🚨 No nutrition data found for '{food_name}' after all attempts, using defaults")
|
| 560 |
default_nutrition = DEFAULT_NUTRITION.copy()
|
| 561 |
default_nutrition["source"] = "Default (APIs unavailable)"
|
| 562 |
return default_nutrition
|
| 563 |
|
| 564 |
+
# ==================== TRANSLATION SYSTEM ====================
|
| 565 |
+
|
| 566 |
+
# In-memory translation cache to reduce API calls
|
| 567 |
+
translation_cache: Dict[str, Dict[str, str]] = {} # {locale: {english: translated}}
|
| 568 |
+
|
| 569 |
+
# Language code mapping (i18n locale → full language name)
|
| 570 |
+
LANGUAGE_MAP = {
|
| 571 |
+
"en": "English",
|
| 572 |
+
"bs": "Bosnian",
|
| 573 |
+
"de": "German",
|
| 574 |
+
"es": "Spanish",
|
| 575 |
+
"fr": "French",
|
| 576 |
+
"it": "Italian",
|
| 577 |
+
"pt": "Portuguese",
|
| 578 |
+
"ar": "Arabic",
|
| 579 |
+
"tr": "Turkish",
|
| 580 |
+
"nl": "Dutch",
|
| 581 |
+
"ru": "Russian",
|
| 582 |
+
"zh": "Chinese",
|
| 583 |
+
"ja": "Japanese",
|
| 584 |
+
"ko": "Korean",
|
| 585 |
+
"hi": "Hindi",
|
| 586 |
+
"sr": "Serbian",
|
| 587 |
+
"hr": "Croatian",
|
| 588 |
+
"sq": "Albanian",
|
| 589 |
+
"mk": "Macedonian",
|
| 590 |
+
}
|
| 591 |
+
|
| 592 |
+
async def translate_food_names_batch(food_names: List[str], target_locale: str) -> Dict[str, str]:
|
| 593 |
+
"""
|
| 594 |
+
Translate multiple food names in a single API call (COST OPTIMIZATION).
|
| 595 |
+
|
| 596 |
+
Args:
|
| 597 |
+
food_names: List of food names in English
|
| 598 |
+
target_locale: Target language code
|
| 599 |
+
|
| 600 |
+
Returns:
|
| 601 |
+
Dictionary mapping original names to translated names
|
| 602 |
+
"""
|
| 603 |
+
# Skip translation if target is English or no OpenAI client
|
| 604 |
+
if target_locale == "en" or not openai_client or not OPENAI_API_KEY:
|
| 605 |
+
return {name: name for name in food_names}
|
| 606 |
+
|
| 607 |
+
# Check cache first
|
| 608 |
+
if target_locale not in translation_cache:
|
| 609 |
+
translation_cache[target_locale] = {}
|
| 610 |
+
|
| 611 |
+
translations = {}
|
| 612 |
+
needs_translation = []
|
| 613 |
+
|
| 614 |
+
# Separate cached and uncached items
|
| 615 |
+
for name in food_names:
|
| 616 |
+
if name in translation_cache[target_locale]:
|
| 617 |
+
translations[name] = translation_cache[target_locale][name]
|
| 618 |
+
logger.info(f"💾 Cache hit: '{name}' → '{translations[name]}' ({target_locale})")
|
| 619 |
+
else:
|
| 620 |
+
needs_translation.append(name)
|
| 621 |
+
|
| 622 |
+
# If all cached, return immediately
|
| 623 |
+
if not needs_translation:
|
| 624 |
+
return translations
|
| 625 |
+
|
| 626 |
+
# Get target language name
|
| 627 |
+
target_language = LANGUAGE_MAP.get(target_locale, target_locale)
|
| 628 |
+
|
| 629 |
+
try:
|
| 630 |
+
logger.info(f"🌐 Batch translating {len(needs_translation)} items to {target_language}")
|
| 631 |
+
|
| 632 |
+
# Create batch translation prompt (1 API call for multiple items)
|
| 633 |
+
food_list = "\n".join(f"{i+1}. {name}" for i, name in enumerate(needs_translation))
|
| 634 |
+
|
| 635 |
+
response = await openai_client.chat.completions.create(
|
| 636 |
+
model="gpt-4o-mini",
|
| 637 |
+
messages=[
|
| 638 |
+
{
|
| 639 |
+
"role": "system",
|
| 640 |
+
"content": f"You are a professional food translator. Translate food names to {target_language}. Return ONLY the translations, one per line, in the same order. Keep it natural and commonly used."
|
| 641 |
+
},
|
| 642 |
+
{
|
| 643 |
+
"role": "user",
|
| 644 |
+
"content": f"Translate these food names to {target_language}:\n{food_list}"
|
| 645 |
+
}
|
| 646 |
+
],
|
| 647 |
+
max_tokens=150,
|
| 648 |
+
temperature=0.3,
|
| 649 |
+
)
|
| 650 |
+
|
| 651 |
+
translated_lines = response.choices[0].message.content.strip().split('\n')
|
| 652 |
+
|
| 653 |
+
# Parse translations and update cache
|
| 654 |
+
for i, name in enumerate(needs_translation):
|
| 655 |
+
if i < len(translated_lines):
|
| 656 |
+
# Remove numbering if present (e.g., "1. Ananas" → "Ananas")
|
| 657 |
+
translated = translated_lines[i].strip()
|
| 658 |
+
translated = translated.split('. ', 1)[-1] if '. ' in translated else translated
|
| 659 |
+
|
| 660 |
+
translations[name] = translated
|
| 661 |
+
translation_cache[target_locale][name] = translated
|
| 662 |
+
logger.info(f"✅ '{name}' → '{translated}'")
|
| 663 |
+
|
| 664 |
+
return translations
|
| 665 |
+
|
| 666 |
+
except Exception as e:
|
| 667 |
+
logger.warning(f"⚠️ Batch translation failed: {e}")
|
| 668 |
+
# Return originals on failure
|
| 669 |
+
for name in needs_translation:
|
| 670 |
+
translations[name] = name
|
| 671 |
+
return translations
|
| 672 |
+
|
| 673 |
+
async def translate_food_name(food_name: str, target_locale: str) -> str:
|
| 674 |
+
"""
|
| 675 |
+
Translate single food name (uses batch function internally for caching).
|
| 676 |
+
|
| 677 |
+
Args:
|
| 678 |
+
food_name: Food name in English
|
| 679 |
+
target_locale: Target language code
|
| 680 |
+
|
| 681 |
+
Returns:
|
| 682 |
+
Translated food name or original if translation fails/not needed
|
| 683 |
+
"""
|
| 684 |
+
result = await translate_food_names_batch([food_name], target_locale)
|
| 685 |
+
return result.get(food_name, food_name)
|
| 686 |
+
|
| 687 |
+
async def translate_description(description: str, target_locale: str) -> str:
|
| 688 |
+
"""
|
| 689 |
+
Translate food description to target language using OpenAI with caching.
|
| 690 |
+
|
| 691 |
+
Args:
|
| 692 |
+
description: Description in English
|
| 693 |
+
target_locale: Target language code
|
| 694 |
+
|
| 695 |
+
Returns:
|
| 696 |
+
Translated description or original if translation fails/not needed
|
| 697 |
+
"""
|
| 698 |
+
# Skip translation if target is English or no OpenAI client
|
| 699 |
+
if target_locale == "en" or not openai_client or not OPENAI_API_KEY:
|
| 700 |
+
return description
|
| 701 |
+
|
| 702 |
+
# Simple cache key (hash of description + locale)
|
| 703 |
+
cache_key = f"desc_{hash(description)}_{target_locale}"
|
| 704 |
+
|
| 705 |
+
# Check if cached in locale cache
|
| 706 |
+
if target_locale not in translation_cache:
|
| 707 |
+
translation_cache[target_locale] = {}
|
| 708 |
+
|
| 709 |
+
if cache_key in translation_cache[target_locale]:
|
| 710 |
+
logger.info(f"💾 Description cache hit ({target_locale})")
|
| 711 |
+
return translation_cache[target_locale][cache_key]
|
| 712 |
+
|
| 713 |
+
# Get target language name
|
| 714 |
+
target_language = LANGUAGE_MAP.get(target_locale, target_locale)
|
| 715 |
+
|
| 716 |
+
try:
|
| 717 |
+
logger.info(f"🌐 Translating description to {target_language}")
|
| 718 |
+
|
| 719 |
+
response = await openai_client.chat.completions.create(
|
| 720 |
+
model="gpt-4o-mini",
|
| 721 |
+
messages=[
|
| 722 |
+
{
|
| 723 |
+
"role": "system",
|
| 724 |
+
"content": f"You are a food description translator. Translate to {target_language}. Keep it natural and concise. Return ONLY the translation."
|
| 725 |
+
},
|
| 726 |
+
{
|
| 727 |
+
"role": "user",
|
| 728 |
+
"content": description
|
| 729 |
+
}
|
| 730 |
+
],
|
| 731 |
+
max_tokens=100,
|
| 732 |
+
temperature=0.3,
|
| 733 |
+
)
|
| 734 |
+
|
| 735 |
+
translated = response.choices[0].message.content.strip()
|
| 736 |
+
|
| 737 |
+
# Cache the result
|
| 738 |
+
translation_cache[target_locale][cache_key] = translated
|
| 739 |
+
logger.info(f"✅ Description translated to {target_language}")
|
| 740 |
+
|
| 741 |
+
return translated
|
| 742 |
+
|
| 743 |
+
except Exception as e:
|
| 744 |
+
logger.warning(f"⚠️ Description translation failed: {e}")
|
| 745 |
+
return description
|
| 746 |
+
|
| 747 |
# ==================== MULTI-MODEL FOOD RECOGNIZER ====================
|
| 748 |
class MultiModelFoodRecognizer:
|
| 749 |
"""Production-ready multi-model ensemble for comprehensive food recognition."""
|
|
|
|
| 901 |
"""Main predict method - uses ensemble if available, fallback to primary."""
|
| 902 |
return self.predict_ensemble(image, top_k)
|
| 903 |
|
| 904 |
+
def predict_ensemble(self, image: Image.Image, top_k: int = 10) -> Dict[str, Any]:
|
| 905 |
+
"""Ensemble prediction using all available models with smart filtering."""
|
| 906 |
if not self.is_loaded:
|
| 907 |
raise RuntimeError("Models not loaded")
|
| 908 |
+
|
| 909 |
all_predictions = []
|
| 910 |
model_results = {}
|
| 911 |
+
|
| 912 |
+
# Get MORE predictions from all models (top 15 instead of 5)
|
| 913 |
+
predictions_per_model = 15
|
| 914 |
+
|
| 915 |
for model_key in self.available_models:
|
| 916 |
+
predictions = self._predict_with_model(image, model_key, predictions_per_model)
|
| 917 |
if predictions:
|
| 918 |
model_results[model_key] = predictions
|
| 919 |
all_predictions.extend(predictions)
|
| 920 |
+
|
| 921 |
if not all_predictions:
|
| 922 |
raise RuntimeError("No models produced valid predictions")
|
| 923 |
+
|
| 924 |
+
# NON-FOOD items that should be COMPLETELY FILTERED OUT
|
| 925 |
+
non_food_items = {
|
| 926 |
+
# Kitchen utensils & cookware
|
| 927 |
+
'plate', 'dish', 'bowl', 'cup', 'glass', 'mug', 'spoon', 'fork', 'knife',
|
| 928 |
+
'spatula', 'pan', 'pot', 'tray', 'napkin', 'table', 'cloth', 'placemat',
|
| 929 |
+
'chopsticks', 'straw', 'bottle', 'container', 'lid', 'wrapper', 'packaging',
|
| 930 |
+
'cutting board', 'grater', 'whisk', 'ladle', 'tongs', 'peeler', 'sieve',
|
| 931 |
+
'colander', 'mixer', 'blender', 'toaster', 'oven', 'microwave', 'fridge',
|
| 932 |
+
'freezer', 'dishwasher', 'sink', 'counter', 'shelf', 'cabinet', 'drawer',
|
| 933 |
+
'waffle iron', 'frying pan', 'frypan', 'skillet', 'saucepan', 'stockpot',
|
| 934 |
+
'baking sheet', 'baking pan', 'baking dish', 'loaf pan', 'muffin tin',
|
| 935 |
+
'rolling pin', 'measuring cup', 'measuring spoon', 'kitchen scale',
|
| 936 |
+
'bakery', 'bakeshop', 'bakehouse', 'restaurant', 'kitchen', 'dining room',
|
| 937 |
+
|
| 938 |
+
# Animals (NOT food!)
|
| 939 |
+
'dog', 'cat', 'bird', 'fish', 'horse', 'cow', 'pig', 'chicken',
|
| 940 |
+
'terrier', 'retriever', 'bulldog', 'poodle', 'beagle', 'dachshund',
|
| 941 |
+
'lobster', 'crab', 'shrimp', 'hunting dog', 'hyena', 'wolf', 'fox',
|
| 942 |
+
|
| 943 |
+
# Objects/Electronics
|
| 944 |
+
'joystick', 'controller', 'remote', 'phone', 'computer', 'mouse', 'keyboard',
|
| 945 |
+
'water jug', 'jug', 'pitcher', 'vase', 'flowerpot'
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
# Generic FOOD terms that should be deprioritized (but not removed)
|
| 949 |
+
generic_terms = {
|
| 950 |
+
'fruit', 'vegetable', 'food', 'meal', 'snack', 'dessert',
|
| 951 |
+
'salad', 'soup', 'drink', 'beverage', 'meat', 'fish', 'seafood',
|
| 952 |
+
'bread', 'pastry', 'cake', 'cookie', 'candy', 'chocolate'
|
| 953 |
+
}
|
| 954 |
+
|
| 955 |
# Ensemble voting: weight by model priority and confidence
|
| 956 |
food_scores = {}
|
| 957 |
+
filtered_count = 0
|
| 958 |
+
|
| 959 |
for pred in all_predictions:
|
| 960 |
+
food_label_lower = pred["raw_label"].lower().replace("_", " ")
|
| 961 |
+
|
| 962 |
+
# FILTER OUT non-food items completely
|
| 963 |
+
is_non_food = any(non_food in food_label_lower for non_food in non_food_items)
|
| 964 |
+
if is_non_food:
|
| 965 |
+
filtered_count += 1
|
| 966 |
+
logger.info(f"🚫 Filtered non-food item: '{pred['raw_label']}'")
|
| 967 |
+
continue # Skip this prediction entirely
|
| 968 |
+
|
| 969 |
model_key = pred["model"]
|
| 970 |
priority_weight = 1.0 / FOOD_MODELS[model_key]["priority"] # Higher priority = lower number = higher weight
|
| 971 |
confidence_weight = pred["confidence"]
|
| 972 |
+
|
| 973 |
+
# PENALTY for generic terms - reduce their score significantly
|
| 974 |
+
is_generic = any(generic in food_label_lower for generic in generic_terms)
|
| 975 |
+
|
| 976 |
+
# If it's a single-word generic term, penalize it even more
|
| 977 |
+
is_single_generic = food_label_lower in generic_terms
|
| 978 |
+
|
| 979 |
+
if is_single_generic:
|
| 980 |
+
combined_score = priority_weight * confidence_weight * 0.1 # 90% penalty
|
| 981 |
+
elif is_generic:
|
| 982 |
+
combined_score = priority_weight * confidence_weight * 0.5 # 50% penalty
|
| 983 |
+
else:
|
| 984 |
+
combined_score = priority_weight * confidence_weight # Full score for specific items
|
| 985 |
+
|
| 986 |
food_name = pred["raw_label"]
|
| 987 |
if food_name not in food_scores:
|
| 988 |
food_scores[food_name] = {
|
| 989 |
"total_score": 0,
|
| 990 |
"count": 0,
|
| 991 |
"best_prediction": pred,
|
| 992 |
+
"models": [],
|
| 993 |
+
"is_generic": is_generic
|
| 994 |
}
|
| 995 |
+
|
| 996 |
food_scores[food_name]["total_score"] += combined_score
|
| 997 |
food_scores[food_name]["count"] += 1
|
| 998 |
food_scores[food_name]["models"].append(model_key)
|
| 999 |
+
|
| 1000 |
# Keep the prediction with highest confidence as representative
|
| 1001 |
if pred["confidence"] > food_scores[food_name]["best_prediction"]["confidence"]:
|
| 1002 |
food_scores[food_name]["best_prediction"] = pred
|
| 1003 |
+
|
| 1004 |
+
if filtered_count > 0:
|
| 1005 |
+
logger.info(f"✅ Filtered out {filtered_count} non-food items")
|
| 1006 |
+
|
| 1007 |
# Sort by ensemble score
|
| 1008 |
sorted_foods = sorted(
|
| 1009 |
+
food_scores.items(),
|
| 1010 |
+
key=lambda x: x[1]["total_score"],
|
| 1011 |
reverse=True
|
| 1012 |
)
|
| 1013 |
+
|
| 1014 |
+
# Format final results - return MORE alternatives (up to top_k)
|
| 1015 |
final_predictions = []
|
| 1016 |
+
for food_name, data in sorted_foods[:top_k * 2]: # Get double to have enough after filtering
|
| 1017 |
pred = data["best_prediction"].copy()
|
| 1018 |
pred["ensemble_score"] = data["total_score"]
|
| 1019 |
pred["model_count"] = data["count"]
|
| 1020 |
pred["contributing_models"] = data["models"]
|
| 1021 |
+
pred["is_generic"] = data["is_generic"]
|
| 1022 |
final_predictions.append(pred)
|
| 1023 |
+
|
| 1024 |
+
# Remove duplicates AND non-food items (double check)
|
| 1025 |
+
filtered_predictions = []
|
| 1026 |
+
seen_labels = set()
|
| 1027 |
+
|
| 1028 |
+
for pred in final_predictions:
|
| 1029 |
+
label_lower = pred["raw_label"].lower().replace("_", " ").strip()
|
| 1030 |
+
|
| 1031 |
+
# DOUBLE CHECK: Filter non-food items again
|
| 1032 |
+
is_non_food = any(non_food in label_lower for non_food in non_food_items)
|
| 1033 |
+
if is_non_food:
|
| 1034 |
+
continue # Skip non-food items
|
| 1035 |
+
|
| 1036 |
+
# Skip if we've already seen very similar label
|
| 1037 |
+
if label_lower not in seen_labels:
|
| 1038 |
+
filtered_predictions.append(pred)
|
| 1039 |
+
seen_labels.add(label_lower)
|
| 1040 |
+
|
| 1041 |
+
if len(filtered_predictions) >= top_k:
|
| 1042 |
+
break
|
| 1043 |
+
|
| 1044 |
+
# Primary result - prefer specific over generic AND high confidence
|
| 1045 |
+
primary = filtered_predictions[0] if filtered_predictions else {
|
| 1046 |
"label": "Unknown Food",
|
| 1047 |
"raw_label": "unknown",
|
| 1048 |
"confidence": 0.0,
|
| 1049 |
"ensemble_score": 0.0,
|
| 1050 |
"model_count": 0,
|
| 1051 |
+
"contributing_models": [],
|
| 1052 |
+
"is_generic": False
|
| 1053 |
}
|
| 1054 |
+
|
| 1055 |
+
# QUALITY CHECK: If primary confidence is < 10%, try to find better alternative
|
| 1056 |
+
MIN_CONFIDENCE = 0.10 # 10%
|
| 1057 |
+
if primary.get("confidence", 0) < MIN_CONFIDENCE and len(filtered_predictions) > 1:
|
| 1058 |
+
logger.warning(f"⚠️ Low confidence ({primary['confidence']:.1%}) for '{primary['label']}', checking alternatives...")
|
| 1059 |
+
# Find first alternative with higher confidence
|
| 1060 |
+
for i, pred in enumerate(filtered_predictions[1:], 1):
|
| 1061 |
+
if pred.get("confidence", 0) >= MIN_CONFIDENCE / 2: # At least 5%
|
| 1062 |
+
filtered_predictions[0], filtered_predictions[i] = filtered_predictions[i], filtered_predictions[0]
|
| 1063 |
+
primary = filtered_predictions[0]
|
| 1064 |
+
logger.info(f"🔄 Swapped low-confidence primary with better alternative: {primary['label']} ({primary['confidence']:.1%})")
|
| 1065 |
+
break
|
| 1066 |
+
|
| 1067 |
+
# If primary is generic but we have specific alternatives, swap them
|
| 1068 |
+
if primary.get("is_generic") and len(filtered_predictions) > 1:
|
| 1069 |
+
for i, pred in enumerate(filtered_predictions[1:], 1):
|
| 1070 |
+
if not pred.get("is_generic"):
|
| 1071 |
+
# Swap primary with this specific prediction
|
| 1072 |
+
filtered_predictions[0], filtered_predictions[i] = filtered_predictions[i], filtered_predictions[0]
|
| 1073 |
+
primary = filtered_predictions[0]
|
| 1074 |
+
logger.info(f"🔄 Swapped generic primary with specific: {primary['label']}")
|
| 1075 |
+
break
|
| 1076 |
+
|
| 1077 |
return {
|
| 1078 |
"success": True,
|
| 1079 |
"label": primary["label"],
|
| 1080 |
"confidence": primary["confidence"],
|
| 1081 |
"primary_label": primary["raw_label"],
|
| 1082 |
"ensemble_score": primary.get("ensemble_score", 0),
|
| 1083 |
+
"alternatives": filtered_predictions[1:], # Now returns up to 9 alternatives
|
| 1084 |
"model_results": model_results,
|
| 1085 |
"system_info": {
|
| 1086 |
"available_models": self.available_models,
|
|
|
|
| 1102 |
logger.info(f"🖥️ Device: {device.upper()}")
|
| 1103 |
logger.info(f"📊 Models: {len(recognizer.available_models)} active models")
|
| 1104 |
logger.info(f"🎯 Total Food Categories: {sum(FOOD_MODELS[m]['classes'] for m in recognizer.available_models)}")
|
| 1105 |
+
logger.info(f"🌐 Translations: {'✅ Enabled' if openai_client else '❌ Disabled'}")
|
| 1106 |
logger.info("=" * 60)
|
| 1107 |
+
|
| 1108 |
yield
|
| 1109 |
|
| 1110 |
# Shutdown
|
|
|
|
| 1127 |
device = select_device()
|
| 1128 |
recognizer = MultiModelFoodRecognizer(device)
|
| 1129 |
|
| 1130 |
+
# Initialize OpenAI client BEFORE FastAPI app
|
| 1131 |
+
if OPENAI_API_KEY:
|
| 1132 |
+
try:
|
| 1133 |
+
openai_client = AsyncOpenAI(api_key=OPENAI_API_KEY)
|
| 1134 |
+
logger.info(f"✅ OpenAI client initialized (key: {OPENAI_API_KEY[:20]}...)")
|
| 1135 |
+
except Exception as e:
|
| 1136 |
+
logger.warning(f"⚠️ OpenAI client initialization failed: {e}")
|
| 1137 |
+
openai_client = None
|
| 1138 |
+
else:
|
| 1139 |
+
logger.warning("⚠️ OpenAI API key not found - translations disabled")
|
| 1140 |
+
|
| 1141 |
# Create FastAPI app
|
| 1142 |
app = FastAPI(
|
| 1143 |
title="AI Food Recognition API",
|
|
|
|
| 1225 |
}
|
| 1226 |
|
| 1227 |
@app.post("/api/nutrition/analyze-food")
|
| 1228 |
+
async def analyze_food_nutrition(request: Request, file: UploadFile = File(None)):
|
| 1229 |
"""
|
| 1230 |
+
Analyze food image or manual entry for Next.js frontend.
|
| 1231 |
+
|
| 1232 |
+
Supports two modes:
|
| 1233 |
+
1. Image upload: AI recognition + nutrition lookup
|
| 1234 |
+
2. Manual entry: Direct nutrition lookup by food name
|
| 1235 |
+
|
| 1236 |
+
Returns nutrition-focused response format with translations.
|
| 1237 |
"""
|
|
|
|
|
|
|
| 1238 |
try:
|
| 1239 |
+
# Parse form data
|
| 1240 |
+
form_data = await request.form()
|
| 1241 |
+
manual_input = form_data.get("manualInput", "false").lower() == "true"
|
| 1242 |
+
locale = form_data.get("locale", "en") # Get user's language preference
|
| 1243 |
+
|
| 1244 |
+
logger.info(f"📥 Request received - Mode: {'Manual' if manual_input else 'Image'}, Locale: {locale}")
|
| 1245 |
+
|
| 1246 |
+
# MODE 1: Manual food entry (from alternatives or manual input)
|
| 1247 |
+
if manual_input:
|
| 1248 |
+
food_name = form_data.get("manualFoodName")
|
| 1249 |
+
serving_size = form_data.get("manualServingSize", "100")
|
| 1250 |
+
serving_unit = form_data.get("manualServingUnit", "g")
|
| 1251 |
+
description = form_data.get("manualDescription", "")
|
| 1252 |
+
|
| 1253 |
+
if not food_name:
|
| 1254 |
+
raise HTTPException(status_code=400, detail="manualFoodName is required for manual entry")
|
| 1255 |
+
|
| 1256 |
+
logger.info(f"🍽️ Manual nutrition lookup: {food_name} ({serving_size}{serving_unit})")
|
| 1257 |
+
|
| 1258 |
+
# Direct nutrition API lookup
|
| 1259 |
+
nutrition_data = await get_nutrition_from_apis(food_name)
|
| 1260 |
+
|
| 1261 |
+
if not nutrition_data or nutrition_data.get("calories", 0) == 0:
|
| 1262 |
+
raise HTTPException(
|
| 1263 |
+
status_code=404,
|
| 1264 |
+
detail=f"Failed to retrieve nutrition information for manual entry"
|
| 1265 |
+
)
|
| 1266 |
+
|
| 1267 |
+
source = nutrition_data.get("source", "Unknown")
|
| 1268 |
+
logger.info(f"✅ Manual lookup: {food_name} | Nutrition: {source}")
|
| 1269 |
+
|
| 1270 |
+
# Translate food name and description
|
| 1271 |
+
translated_name = await translate_food_name(food_name, locale)
|
| 1272 |
+
base_description = description or f"Manual entry: {food_name}"
|
| 1273 |
+
translated_description = await translate_description(base_description, locale)
|
| 1274 |
+
|
| 1275 |
+
# Return manual entry format
|
| 1276 |
+
return JSONResponse(content={
|
| 1277 |
+
"data": {
|
| 1278 |
+
"label": translated_name,
|
| 1279 |
+
"confidence": 1.0, # Manual entry has 100% confidence
|
| 1280 |
+
"nutrition": {
|
| 1281 |
+
"calories": nutrition_data["calories"],
|
| 1282 |
+
"protein": nutrition_data["protein"],
|
| 1283 |
+
"carbs": nutrition_data["carbs"],
|
| 1284 |
+
"fat": nutrition_data["fat"]
|
| 1285 |
+
},
|
| 1286 |
+
"servingSize": serving_size,
|
| 1287 |
+
"servingUnit": serving_unit,
|
| 1288 |
+
"description": translated_description,
|
| 1289 |
+
"alternatives": [], # No alternatives for manual entry
|
| 1290 |
+
"source": f"{source} Database",
|
| 1291 |
+
"isManualEntry": True
|
| 1292 |
+
}
|
| 1293 |
+
})
|
| 1294 |
+
|
| 1295 |
+
# MODE 2: Image upload (AI recognition)
|
| 1296 |
+
else:
|
| 1297 |
+
if not file:
|
| 1298 |
+
raise HTTPException(status_code=400, detail="File is required for image analysis")
|
| 1299 |
+
|
| 1300 |
+
logger.info(f"🍽️ Image analysis request: {file.filename}")
|
| 1301 |
+
|
| 1302 |
+
# Validate and process image
|
| 1303 |
+
image = await validate_and_read_image(file)
|
| 1304 |
+
|
| 1305 |
+
# Step 1: AI Model Prediction (request top 10 for more alternatives)
|
| 1306 |
+
results = recognizer.predict(image, top_k=10)
|
| 1307 |
+
|
| 1308 |
+
# Step 2: API Nutrition Lookup
|
| 1309 |
+
nutrition_data = await get_nutrition_from_apis(results["primary_label"])
|
| 1310 |
+
|
| 1311 |
+
# Log result
|
| 1312 |
+
confidence_pct = f"{results['confidence']:.1%}"
|
| 1313 |
+
source = nutrition_data.get("source", "Unknown")
|
| 1314 |
+
logger.info(f"✅ Prediction: {results['label']} ({confidence_pct}) | Nutrition: {source}")
|
| 1315 |
+
|
| 1316 |
+
# BATCH TRANSLATION OPTIMIZATION: Translate all food names at once
|
| 1317 |
+
if locale != "en" and openai_client:
|
| 1318 |
+
# Collect all names to translate (primary + alternatives)
|
| 1319 |
+
names_to_translate = [results["label"]]
|
| 1320 |
+
if results.get("alternatives"):
|
| 1321 |
+
names_to_translate.extend([
|
| 1322 |
+
alt.get("label", alt.get("raw_label", ""))
|
| 1323 |
+
for alt in results["alternatives"]
|
| 1324 |
+
])
|
| 1325 |
+
|
| 1326 |
+
# Single API call for all translations
|
| 1327 |
+
translations = await translate_food_names_batch(names_to_translate, locale)
|
| 1328 |
+
|
| 1329 |
+
# Apply translations
|
| 1330 |
+
translated_name = translations.get(results["label"], results["label"])
|
| 1331 |
+
|
| 1332 |
+
# Translate description
|
| 1333 |
+
base_description = f"{results['label']} identified with {int(results['confidence'] * 100)}% confidence"
|
| 1334 |
+
translated_description = await translate_description(base_description, locale)
|
| 1335 |
+
|
| 1336 |
+
# Map alternatives with translations
|
| 1337 |
+
translated_alternatives = []
|
| 1338 |
+
if results.get("alternatives"):
|
| 1339 |
+
for alt in results["alternatives"]:
|
| 1340 |
+
alt_name = alt.get("label", alt.get("raw_label", ""))
|
| 1341 |
+
translated_alternatives.append({
|
| 1342 |
+
**alt,
|
| 1343 |
+
"label": translations.get(alt_name, alt_name),
|
| 1344 |
+
"original_label": alt_name
|
| 1345 |
+
})
|
| 1346 |
+
else:
|
| 1347 |
+
# No translation needed
|
| 1348 |
+
translated_name = results["label"]
|
| 1349 |
+
translated_description = f"{results['label']} identified with {int(results['confidence'] * 100)}% confidence"
|
| 1350 |
+
translated_alternatives = results["alternatives"]
|
| 1351 |
+
|
| 1352 |
+
# Return frontend-expected format
|
| 1353 |
+
return JSONResponse(content={
|
| 1354 |
+
"data": {
|
| 1355 |
+
"label": translated_name,
|
| 1356 |
+
"confidence": results["confidence"],
|
| 1357 |
+
"description": translated_description, # Translated description
|
| 1358 |
+
"nutrition": {
|
| 1359 |
+
"calories": nutrition_data["calories"],
|
| 1360 |
+
"protein": nutrition_data["protein"],
|
| 1361 |
+
"carbs": nutrition_data["carbs"],
|
| 1362 |
+
"fat": nutrition_data["fat"]
|
| 1363 |
+
},
|
| 1364 |
+
"alternatives": translated_alternatives,
|
| 1365 |
+
"source": f"AI Recognition + {source} Database",
|
| 1366 |
+
"isManualEntry": False,
|
| 1367 |
+
"locale": locale # Return locale for debugging
|
| 1368 |
+
}
|
| 1369 |
+
})
|
| 1370 |
+
|
| 1371 |
except HTTPException:
|
| 1372 |
raise
|
| 1373 |
except Exception as e:
|
|
|
|
| 1387 |
# Validate and process image
|
| 1388 |
image = await validate_and_read_image(file)
|
| 1389 |
|
| 1390 |
+
# Step 1: AI Model Prediction (request top 10 for more alternatives)
|
| 1391 |
+
results = recognizer.predict(image, top_k=10)
|
| 1392 |
|
| 1393 |
# Step 2: API Nutrition Lookup
|
| 1394 |
nutrition_data = await get_nutrition_from_apis(results["primary_label"])
|
requirements.txt
CHANGED
|
@@ -21,6 +21,9 @@ python-multipart>=0.0.6
|
|
| 21 |
# Async HTTP client for USDA API
|
| 22 |
aiohttp>=3.8.0
|
| 23 |
|
|
|
|
|
|
|
|
|
|
| 24 |
# Utilities
|
| 25 |
python-dotenv>=1.0.0
|
| 26 |
|
|
|
|
| 21 |
# Async HTTP client for USDA API
|
| 22 |
aiohttp>=3.8.0
|
| 23 |
|
| 24 |
+
# OpenAI for translations
|
| 25 |
+
openai>=1.0.0
|
| 26 |
+
|
| 27 |
# Utilities
|
| 28 |
python-dotenv>=1.0.0
|
| 29 |
|