har1zarD commited on
Commit
f17a8cd
·
1 Parent(s): ed6d136

feat: simplify to Hugging Face Vision Transformer model and reduce dependencies

Browse files
Files changed (3) hide show
  1. app.py +78 -615
  2. requirements.txt +6 -20
  3. yolov8n.pt +3 -0
app.py CHANGED
@@ -1,643 +1,106 @@
1
- #!/usr/bin/env python3
2
- """
3
- Optimized Food Recognition Backend
4
- Fast CLIP-based food identification + Open Food Facts nutrition
5
- """
6
 
7
- import asyncio
8
- import aiohttp
9
- import json
10
- import logging
11
  import os
12
- import re
13
- import time
14
  from io import BytesIO
15
- from pathlib import Path
16
- from typing import Dict, List, Optional, Any, Tuple
17
- from contextlib import asynccontextmanager
18
-
19
- import torch
20
- from PIL import Image
21
- from transformers import CLIPProcessor, CLIPModel, AutoFeatureExtractor, AutoModelForImageClassification, pipeline
22
 
 
23
  from fastapi import FastAPI, File, UploadFile, HTTPException, Query
24
- from fastapi.middleware.cors import CORSMiddleware
25
  from fastapi.responses import JSONResponse
26
- from pydantic import BaseModel, Field
27
- import uvicorn
28
-
29
- # Configure logging
30
- logging.basicConfig(level=logging.INFO)
31
- logger = logging.getLogger(__name__)
32
-
33
- # Configuration
34
- class Config:
35
- """Application configuration"""
36
- # Server Configuration
37
- HOST = os.getenv("HOST", "0.0.0.0")
38
- PORT = int(os.getenv("PORT", "8000"))
39
-
40
- # Device Configuration
41
- DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
42
-
43
- # Open Food Facts API
44
- OFF_API_BASE = "https://world.openfoodfacts.org/api/v0"
45
- OFF_SEARCH_URL = "https://world.openfoodfacts.org/cgi/search.pl"
46
- OFF_USER_AGENT = "FoodRecognitionApp/1.0 ([email protected])"
47
-
48
- # Nutrition APIs - Load from environment variables
49
- USDA_API_KEY = os.getenv("USDA_API_KEY", "kgw5ZaUGy92zoFoCzAo1pGq688u0jYXEA17ZlzO9")
50
- NUTRITIONIX_APP_ID = os.getenv("NUTRITIONIX_APP_ID", "4224c603")
51
- NUTRITIONIX_API_KEY = os.getenv("NUTRITIONIX_API_KEY", "3f4717bb1433fcbf57799a36318301ab")
52
-
53
- # Model Configuration - Switch between models by commenting/uncommenting the lines below
54
- MODEL_NAME = "nateraw/food" # Specialized food classifier (high accuracy, currently active)
55
- # MODEL_NAME = "dwililiya/food101-model-classification" # EfficientNet-B0, 101 specific foods, lighter than nateraw
56
- # MODEL_NAME = "google/mobilenet_v2_1.0_224" # Google MobileNet v2 (general purpose, not food-specific)
57
-
58
- # Best Hugging Face models for food recognition
59
- FOOD_MODELS = {
60
- "primary": MODEL_NAME, # Currently selected model
61
- "secondary": "google/vit-base-patch16-224", # Vision Transformer
62
- "fallback": "microsoft/resnet-50", # ResNet for general classification
63
- "food_specific": "Kaludi/food-category-classification-v2.0", # Another food-specific model
64
- }
65
-
66
- config = Config()
67
-
68
- # Data Models
69
- class NutritionInfo(BaseModel):
70
- calories: float = Field(..., description="Calories per 100g")
71
- protein: float = Field(..., description="Protein in grams per 100g")
72
- fat: float = Field(..., description="Fat in grams per 100g")
73
- carbs: float = Field(..., description="Carbohydrates in grams per 100g")
74
- fiber: Optional[float] = Field(None, description="Fiber in grams per 100g")
75
- sugar: Optional[float] = Field(None, description="Sugar in grams per 100g")
76
- sodium: Optional[float] = Field(None, description="Sodium in mg per 100g")
77
-
78
- class FoodAnalysisResponse(BaseModel):
79
- label: str = Field(..., description="Identified food name")
80
- confidence: float = Field(..., description="Recognition confidence (0-1)")
81
- nutrition: NutritionInfo = Field(..., description="Nutritional information")
82
- alternatives: List[str] = Field(default=[], description="Alternative food predictions")
83
- source: str = Field(..., description="Data source")
84
- off_product_id: Optional[str] = Field(None, description="Open Food Facts product ID")
85
-
86
- class ErrorResponse(BaseModel):
87
- error: str
88
- detail: Optional[str] = None
89
-
90
- # Professional Food Recognition Model
91
- class FoodRecognitionModel:
92
- """Professional food recognition using specialized Hugging Face models"""
93
-
94
- def __init__(self):
95
- self.device = config.DEVICE
96
- self.primary_model = None
97
- self.secondary_model = None
98
- self.food_pipeline = None
99
- self._load_models()
100
-
101
- def _load_models(self):
102
- """Load specialized food recognition models"""
103
- try:
104
- logger.info(f"Loading specialized food recognition models on {self.device}")
105
-
106
- # Load primary food-specific model
107
- try:
108
- logger.info(f"Loading primary food model: {FOOD_MODELS['primary']}")
109
- self.food_pipeline = pipeline(
110
- "image-classification",
111
- model=FOOD_MODELS["primary"],
112
- device=0 if "cuda" in str(self.device) else -1
113
- )
114
- logger.info("✅ Primary food model loaded successfully")
115
- except Exception as e:
116
- logger.warning(f"Primary model failed: {e}, trying secondary...")
117
-
118
- # Fallback to secondary model
119
- try:
120
- logger.info("Loading secondary food model: Kaludi/food-category-classification-v2.0")
121
- self.food_pipeline = pipeline(
122
- "image-classification",
123
- model=FOOD_MODELS["food_specific"],
124
- device=0 if "cuda" in str(self.device) else -1
125
- )
126
- logger.info("✅ Secondary food model loaded successfully")
127
- except Exception as e2:
128
- logger.warning(f"Secondary model failed: {e2}, using Vision Transformer...")
129
-
130
- # Final fallback to ViT
131
- self.food_pipeline = pipeline(
132
- "image-classification",
133
- model=FOOD_MODELS["secondary"],
134
- device=0 if "cuda" in str(self.device) else -1
135
- )
136
- logger.info("✅ Vision Transformer model loaded as fallback")
137
-
138
- except Exception as e:
139
- logger.error(f"Failed to load any food recognition model: {e}")
140
- raise
141
-
142
-
143
- def recognize_food(self, image: Image.Image) -> Tuple[str, float, List[str]]:
144
- """
145
- Professional food recognition using specialized models
146
-
147
- Returns:
148
- (food_name, confidence, alternatives)
149
- """
150
- try:
151
- start_time = time.time()
152
-
153
- # Convert image if needed
154
- if image.mode != 'RGB':
155
- image = image.convert('RGB')
156
-
157
- # Use specialized food recognition pipeline
158
- results = self.food_pipeline(image, top_k=5)
159
-
160
- if not results:
161
- logger.warning("No food predictions returned")
162
- return "unknown food", 0.1, []
163
-
164
- # Extract top prediction
165
- top_result = results[0]
166
- food_name = self._clean_food_label(top_result['label'])
167
- confidence = top_result['score']
168
-
169
- # Get alternatives
170
- alternatives = []
171
- for result in results[1:]:
172
- alt_name = self._clean_food_label(result['label'])
173
- if alt_name != food_name: # Avoid duplicates
174
- alternatives.append(alt_name)
175
-
176
- elapsed = time.time() - start_time
177
- logger.info(f"🎯 Professional food recognition in {elapsed:.2f}s: {food_name} ({confidence:.3f})")
178
-
179
- return food_name, confidence, alternatives[:4] # Return top 4 alternatives
180
-
181
- except Exception as e:
182
- logger.error(f"Food recognition failed: {e}")
183
- return "unknown food", 0.1, []
184
-
185
- def _clean_food_label(self, label: str) -> str:
186
- """Clean food label from model output"""
187
- # Remove common prefixes/suffixes from model labels
188
- cleaned = label.lower().strip()
189
-
190
- # Remove model-specific prefixes
191
- prefixes_to_remove = ['food_', 'dish_', 'meal_']
192
- for prefix in prefixes_to_remove:
193
- if cleaned.startswith(prefix):
194
- cleaned = cleaned[len(prefix):]
195
-
196
- # Replace underscores with spaces
197
- cleaned = cleaned.replace('_', ' ')
198
-
199
- # Remove extra spaces
200
- cleaned = ' '.join(cleaned.split())
201
-
202
- return cleaned
203
 
204
- # Optimized Open Food Facts Client
205
- class FastNutritionClient:
206
- """Fast nutrition data client with better error handling"""
207
 
208
- def __init__(self):
209
- self.session = None
210
- self.timeout = aiohttp.ClientTimeout(total=5, connect=2) # Very fast timeout
211
-
212
- async def __aenter__(self):
213
- self.session = aiohttp.ClientSession(timeout=self.timeout)
214
- return self
215
-
216
- async def __aexit__(self, exc_type, exc_val, exc_tb):
217
- if self.session:
218
- await self.session.close()
219
-
220
- async def get_nutrition(self, food_name: str) -> Optional[Tuple[NutritionInfo, str, Optional[str]]]:
221
- """
222
- Nutrition data lookup from Open Food Facts only - NO FALLBACKS
223
-
224
- Returns:
225
- (nutrition_info, source, product_id) or None if not found in OFF
226
- """
227
- try:
228
- # Try multiple search strategies for better results
229
- search_terms = self._generate_search_terms(food_name)
230
-
231
- for search_term in search_terms:
232
- try:
233
- result = await asyncio.wait_for(self._search_off(search_term), timeout=2.0)
234
- if result:
235
- return result
236
- except asyncio.TimeoutError:
237
- logger.debug(f"Timeout searching for '{search_term}'")
238
- continue
239
- except Exception as e:
240
- logger.debug(f"Error searching for '{search_term}': {e}")
241
- continue
242
-
243
- # NO FALLBACK - return None if not found in Open Food Facts
244
- logger.warning(f"No nutrition data found in Open Food Facts for '{food_name}'")
245
- return None
246
-
247
- except Exception as e:
248
- logger.warning(f"Nutrition lookup failed for '{food_name}': {e}")
249
- return None
250
-
251
- def _generate_search_terms(self, food_name: str) -> List[str]:
252
- """Generate multiple search terms for better matching"""
253
- terms = []
254
-
255
- # Original term
256
- terms.append(food_name.lower().strip())
257
-
258
- # Remove descriptive words for broader search
259
- clean_term = food_name.lower()
260
- remove_words = ["american", "fluffy", "stack of", "with butter", "with syrup", "breakfast"]
261
- for word in remove_words:
262
- clean_term = clean_term.replace(word, "").strip()
263
-
264
- if clean_term and clean_term != terms[0]:
265
- terms.append(clean_term)
266
-
267
- # Extract main food word (first meaningful word)
268
- words = clean_term.split()
269
- if words:
270
- main_word = words[0] if len(words[0]) > 3 else (words[1] if len(words) > 1 else words[0])
271
- if main_word not in terms:
272
- terms.append(main_word)
273
-
274
- return terms[:3] # Limit to 3 attempts
275
-
276
- async def _search_off(self, search_term: str) -> Optional[Tuple[NutritionInfo, str, Optional[str]]]:
277
- """Search Open Food Facts with single term"""
278
- try:
279
- params = {
280
- "search_terms": search_term,
281
- "search_simple": 1,
282
- "action": "process",
283
- "json": 1,
284
- "page_size": 5,
285
- "sort_by": "popularity"
286
- }
287
-
288
- headers = {"User-Agent": config.OFF_USER_AGENT}
289
-
290
- # Use asyncio.wait_for for additional timeout protection
291
- search_task = self.session.get(config.OFF_SEARCH_URL, params=params, headers=headers)
292
-
293
- async with await asyncio.wait_for(search_task, timeout=3.0) as response:
294
- if response.status != 200:
295
- return None
296
-
297
- data = await response.json()
298
- products = data.get("products", [])
299
-
300
- # Find best product with nutrition data
301
- for product in products[:3]:
302
- product_name = product.get("product_name", "unknown")
303
- logger.debug(f"Checking product: {product_name}")
304
- nutrition = self._extract_nutrition(product)
305
- if nutrition:
306
- logger.debug(f"Extracted nutrition: {nutrition.calories} kcal")
307
- if self._validate_nutrition(nutrition):
308
- product_id = product.get("code")
309
- logger.info(f"✅ Found nutrition for '{search_term}': {nutrition.calories} kcal")
310
- return nutrition, "Open Food Facts", product_id
311
- else:
312
- logger.debug(f"❌ Nutrition validation failed for {product_name}")
313
- else:
314
- logger.debug(f"❌ Could not extract nutrition from {product_name}")
315
-
316
- except asyncio.TimeoutError:
317
- logger.debug(f"OFF search timed out for '{search_term}'")
318
- except Exception as e:
319
- logger.debug(f"OFF search failed for '{search_term}': {e}")
320
-
321
- return None
322
-
323
- def _safe_float(self, value) -> float:
324
- """Safely convert value to float"""
325
- if not value:
326
- return 0.0
327
- try:
328
- if isinstance(value, str):
329
- cleaned = value.replace(',', '.')
330
- # Handle duplicated decimals like "0.120.12"
331
- if cleaned.count('.') > 1:
332
- parts = cleaned.split('.')
333
- cleaned = f"{parts[0]}.{parts[1][:2]}" # Take first 2 decimal places
334
- return float(cleaned)
335
- return float(value)
336
- except (ValueError, TypeError):
337
- return 0.0
338
-
339
- def _extract_nutrition(self, product: Dict) -> Optional[NutritionInfo]:
340
- """Extract nutrition with improved validation"""
341
- try:
342
- nutriments = product.get("nutriments", {})
343
-
344
- # Get calories from multiple possible fields
345
- calories = 0
346
- for key in ["energy-kcal_100g", "energy_100g"]:
347
- value = nutriments.get(key)
348
- if value:
349
- if key == "energy_100g": # kJ to kcal
350
- calories = self._safe_float(value) / 4.184
351
- else:
352
- calories = self._safe_float(value)
353
- break
354
-
355
- protein = self._safe_float(nutriments.get("proteins_100g", 0))
356
- fat = self._safe_float(nutriments.get("fat_100g", 0))
357
- carbs = self._safe_float(nutriments.get("carbohydrates_100g", 0))
358
-
359
- # Basic validation
360
- if calories <= 0 or calories > 3000:
361
- return None
362
-
363
- # Optional nutrients
364
- fiber = self._safe_float(nutriments.get("fiber_100g")) or None
365
- sugar = self._safe_float(nutriments.get("sugars_100g")) or None
366
- sodium = self._safe_float(nutriments.get("sodium_100g")) or None
367
-
368
- # Convert sodium g to mg
369
- if sodium and sodium > 0:
370
- sodium = sodium * 1000 if sodium < 50 else sodium
371
-
372
- return NutritionInfo(
373
- calories=calories,
374
- protein=protein,
375
- fat=fat,
376
- carbs=carbs,
377
- fiber=fiber,
378
- sugar=sugar,
379
- sodium=sodium
380
- )
381
-
382
- except Exception as e:
383
- logger.debug(f"Nutrition extraction failed: {e}")
384
- return None
385
-
386
- def _validate_nutrition(self, nutrition: NutritionInfo) -> bool:
387
- """Validate nutrition data makes sense"""
388
- return (50 <= nutrition.calories <= 2000 and
389
- 0 <= nutrition.protein <= 100 and
390
- 0 <= nutrition.fat <= 100 and
391
- 0 <= nutrition.carbs <= 100)
392
 
393
- # Global model instances
394
- food_model = None
 
395
 
396
- @asynccontextmanager
397
- async def lifespan(app: FastAPI):
398
- """Initialize models on startup"""
399
- global food_model
400
- logger.info("🚀 Starting Fast Food Recognition Backend...")
401
-
402
- # Load optimized food recognition model
403
- food_model = FoodRecognitionModel()
404
-
405
- logger.info("✅ Backend ready for fast food recognition!")
406
- yield
407
-
408
- # Cleanup on shutdown
409
- logger.info("🛑 Shutting down backend...")
410
 
411
- # FastAPI app
412
  app = FastAPI(
413
- title="Fast Food Recognition Backend",
414
- description="Optimized CLIP-based food identification with Open Food Facts nutrition",
415
- version="3.0.0",
416
- lifespan=lifespan
417
  )
418
 
419
- # CORS middleware
420
- app.add_middleware(
421
- CORSMiddleware,
422
- allow_origins=["*"],
423
- allow_credentials=True,
424
- allow_methods=["*"],
425
- allow_headers=["*"],
426
- )
427
 
428
- # Utility functions
429
- def validate_image(file: UploadFile) -> Image.Image:
430
- """Validate and load uploaded image"""
431
  try:
432
- image_data = file.file.read()
433
- image = Image.open(BytesIO(image_data))
434
-
435
- # Convert to RGB if needed
436
- if image.mode != 'RGB':
437
- image = image.convert('RGB')
438
-
439
- return image
440
-
441
- except Exception as e:
442
- raise HTTPException(
443
- status_code=400,
444
- detail=f"Invalid image file: {str(e)}"
445
- )
446
 
447
- async def validate_image_from_url(image_url: str) -> Image.Image:
448
- """Validate and load image from URL"""
449
  try:
450
- async with aiohttp.ClientSession() as session:
451
- async with session.get(image_url) as response:
452
- if response.status != 200:
453
- raise HTTPException(status_code=400, detail="Could not fetch image from URL")
454
-
455
- image_data = await response.read()
456
- image = Image.open(BytesIO(image_data))
457
-
458
- if image.mode != 'RGB':
459
- image = image.convert('RGB')
460
-
461
- return image
462
-
463
  except Exception as e:
464
- raise HTTPException(
465
- status_code=400,
466
- detail=f"Invalid image URL: {str(e)}"
467
- )
468
 
469
- # API Endpoints
470
-
471
- @app.get("/")
472
- async def root():
473
- """Health check endpoint"""
474
- return {
475
- "status": "healthy",
476
- "message": "Fast Food Recognition Backend",
477
- "version": "3.0.0",
478
- "device": str(config.DEVICE)
479
- }
480
 
481
- @app.get("/health")
482
- async def health_check():
483
- """Detailed health check"""
484
- return {
485
- "status": "healthy",
486
- "model_loaded": food_model is not None,
487
- "device": config.DEVICE,
488
- "food_pipeline_loaded": food_model.food_pipeline is not None if food_model else False,
489
- "model_type": "Professional Food Recognition Models"
490
- }
491
 
492
- @app.post("/analyze", response_model=FoodAnalysisResponse)
493
- async def analyze_food_image(
494
- file: UploadFile = File(..., description="Food image to analyze"),
495
- top_alternatives: int = Query(3, ge=1, le=5, description="Number of alternative predictions")
496
- ):
497
- """
498
- Fast food image analysis with optimized CLIP recognition
499
- """
500
- try:
501
- start_time = time.time()
502
-
503
- # Validate and load image
504
- image = validate_image(file)
505
- logger.info(f"Image loaded in {time.time() - start_time:.2f}s")
506
-
507
- # Fast food recognition - always returns high confidence results
508
- food_name, confidence, alternatives = food_model.recognize_food(image)
509
-
510
- # Get nutrition data
511
- nutrition_start = time.time()
512
- async with FastNutritionClient() as nutrition_client:
513
- nutrition_result = await nutrition_client.get_nutrition(food_name)
514
-
515
- if not nutrition_result:
516
- raise HTTPException(
517
- status_code=422,
518
- detail=f"No nutrition data found for '{food_name}'. Try a different image or food type."
519
- )
520
-
521
- nutrition, source, product_id = nutrition_result
522
- logger.info(f"Nutrition lookup completed in {time.time() - nutrition_start:.2f}s")
523
-
524
- total_time = time.time() - start_time
525
- logger.info(f"🎯 Complete analysis in {total_time:.2f}s: {food_name} ({confidence:.3f})")
526
-
527
- return FoodAnalysisResponse(
528
- label=food_name,
529
- confidence=confidence,
530
- nutrition=nutrition,
531
- alternatives=alternatives[:top_alternatives],
532
- source=source,
533
- off_product_id=product_id
534
  )
535
-
536
- except HTTPException:
537
- raise
538
- except Exception as e:
539
- logger.error(f"Analysis failed: {e}")
540
- raise HTTPException(status_code=500, detail=f"Internal analysis error: {str(e)}")
541
 
542
- @app.post("/analyze-url", response_model=FoodAnalysisResponse)
543
- async def analyze_food_image_from_url(
544
- image_url: str = Query(..., description="URL of food image to analyze"),
545
- top_alternatives: int = Query(3, ge=1, le=5, description="Number of alternative predictions")
546
- ):
547
- """
548
- Fast food image analysis from URL
549
- """
550
- try:
551
- start_time = time.time()
552
-
553
- # Load image from URL
554
- image = await validate_image_from_url(image_url)
555
-
556
- # Fast food recognition - always returns high confidence results
557
- food_name, confidence, alternatives = food_model.recognize_food(image)
558
-
559
- # Get nutrition data
560
- async with FastNutritionClient() as nutrition_client:
561
- nutrition_result = await nutrition_client.get_nutrition(food_name)
562
-
563
- if not nutrition_result:
564
- raise HTTPException(
565
- status_code=422,
566
- detail=f"No nutrition data found for '{food_name}'"
567
- )
568
-
569
- nutrition, source, product_id = nutrition_result
570
-
571
- total_time = time.time() - start_time
572
- logger.info(f"🎯 URL analysis completed in {total_time:.2f}s: {food_name}")
573
-
574
- return FoodAnalysisResponse(
575
- label=food_name,
576
- confidence=confidence,
577
- nutrition=nutrition,
578
- alternatives=alternatives[:top_alternatives],
579
- source=source,
580
- off_product_id=product_id
581
- )
582
-
583
- except HTTPException:
584
- raise
585
- except Exception as e:
586
- logger.error(f"URL analysis failed: {e}")
587
- raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
588
 
589
- @app.get("/search-nutrition/{food_name}")
590
- async def search_nutrition_data(food_name: str):
591
- """
592
- Search for nutrition information for a specific food item
593
- """
594
- try:
595
- async with FastNutritionClient() as nutrition_client:
596
- nutrition_result = await nutrition_client.get_nutrition(food_name)
597
-
598
- if not nutrition_result:
599
- raise HTTPException(
600
- status_code=404,
601
- detail=f"No nutrition data found for '{food_name}'"
602
- )
603
-
604
- nutrition, source, product_id = nutrition_result
605
-
606
- return {
607
- "food_name": food_name,
608
- "nutrition": nutrition,
609
- "source": source,
610
- "off_product_id": product_id
611
- }
612
-
613
- except HTTPException:
614
- raise
615
- except Exception as e:
616
- logger.error(f"Nutrition search failed: {e}")
617
- raise HTTPException(status_code=500, detail=f"Search failed: {str(e)}")
618
 
619
- # Global exception handler
620
- @app.exception_handler(Exception)
621
- async def global_exception_handler(request, exc):
622
- logger.error(f"Global exception: {exc}")
623
- return JSONResponse(
624
- status_code=500,
625
- content={
626
- "error": "Internal server error",
627
- "detail": "An unexpected error occurred"
628
- }
629
- )
630
 
 
631
  if __name__ == "__main__":
632
- # Create backend directory if it doesn't exist
633
- backend_dir = Path(__file__).parent
634
- backend_dir.mkdir(exist_ok=True)
635
-
636
- # Run the server with configuration from Config
637
- uvicorn.run(
638
- "app:app",
639
- host=config.HOST,
640
- port=config.PORT,
641
- reload=True,
642
- log_level="info"
643
- )
 
 
 
 
 
 
1
 
 
 
 
 
2
  import os
 
 
3
  from io import BytesIO
 
 
 
 
 
 
 
4
 
5
+ import uvicorn
6
  from fastapi import FastAPI, File, UploadFile, HTTPException, Query
 
7
  from fastapi.responses import JSONResponse
8
+ from PIL import Image
9
+ from transformers import pipeline
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ # --- Configuration ---
12
+ MODEL_NAME = "google/vit-base-patch16-224"
 
13
 
14
+ # --- Helper Functions ---
15
+ def load_model():
16
+ """Loads a specialized food recognition model from Hugging Face."""
17
+ try:
18
+ print(f"Loading model: {MODEL_NAME}...")
19
+ # Using 'image-classification' pipeline
20
+ # device=0 for CUDA, device=-1 for CPU
21
+ food_classifier = pipeline("image-classification", model=MODEL_NAME, device=-1)
22
+ print("Model loaded successfully.")
23
+ return food_classifier
24
+ except Exception as e:
25
+ print(f"Error loading model: {e}")
26
+ raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
+ def is_image_file(file: UploadFile):
29
+ """Checks if the file is a supported image format (JPEG, PNG)."""
30
+ return file.content_type in ["image/jpeg", "image/png"]
31
 
32
+ # --- Load Model on Application Startup ---
33
+ model = load_model()
 
 
 
 
 
 
 
 
 
 
 
 
34
 
35
+ # --- FastAPI Application ---
36
  app = FastAPI(
37
+ title="Food Scanner API",
38
+ description="API for recognizing food in images using a specialized Hugging Face model.",
39
+ version="2.1.0" # Version updated to reflect translation
 
40
  )
41
 
42
+ @app.post("/analyze")
43
+ async def analyze(file: UploadFile = File(...), top_alternatives: int = Query(3)):
44
+ """Receives an image, performs food detection, and returns the result in JSON format."""
45
+ if not file:
46
+ raise HTTPException(status_code=400, detail="No image sent.")
47
+
48
+ if not is_image_file(file):
49
+ raise HTTPException(status_code=400, detail="Unsupported image format. Use JPEG or PNG.")
50
 
 
 
 
51
  try:
52
+ contents = await file.read()
53
+ image = Image.open(BytesIO(contents))
54
+ except Exception:
55
+ raise HTTPException(status_code=500, detail="Error reading the image.")
 
 
 
 
 
 
 
 
 
 
56
 
 
 
57
  try:
58
+ # Perform prediction
59
+ predictions = model(image, top_k=top_alternatives + 1) # +1 to have the main prediction and alternatives
 
 
 
 
 
 
 
 
 
 
 
60
  except Exception as e:
61
+ raise HTTPException(status_code=500, detail=f"Error during model prediction: {e}")
 
 
 
62
 
63
+ if not predictions:
64
+ raise HTTPException(status_code=404, detail="The model failed to recognize food in the image.")
 
 
 
 
 
 
 
 
 
65
 
66
+ # Process results
67
+ main_prediction = predictions[0]
 
 
 
 
 
 
 
 
68
 
69
+ # --- NEW STEP: Confidence Threshold Check ---
70
+ CONFIDENCE_THRESHOLD = 0.5 # 50% confidence threshold
71
+ if main_prediction["score"] < CONFIDENCE_THRESHOLD:
72
+ raise HTTPException(
73
+ status_code=422, # Unprocessable Entity
74
+ detail=f"Food could not be recognized with sufficient confidence. The model is {main_prediction['score']:.0%} confident that this is a {main_prediction['label'].replace('_', ' ')}."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  )
 
 
 
 
 
 
76
 
77
+ alternatives = [p["label"] for p in predictions[1:]]
78
+
79
+ # Clean up the label name (e.g., replace _ with a space)
80
+ label_name = main_prediction["label"].replace('_', ' ')
81
+
82
+ # Prepare the final response in the format expected by the frontend
83
+ final_response = {
84
+ "label": label_name,
85
+ "confidence": round(main_prediction["score"], 2),
86
+ # Bounding box is no longer available with this model
87
+ "bounding_box": None,
88
+ # Adding a dummy nutrition object to prevent the frontend from crashing
89
+ "nutrition": {
90
+ "calories": 0, "protein": 0, "fat": 0, "carbs": 0,
91
+ "fiber": 0, "sugar": 0, "sodium": 0
92
+ },
93
+ "alternatives": alternatives,
94
+ "source": f"Hugging Face ({MODEL_NAME})",
95
+ "off_product_id": None
96
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
+ return JSONResponse(content=final_response)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
 
100
+ @app.get("/")
101
+ def root():
102
+ return {"message": "Food Scanner API v2.1 is running. Send a POST request to /analyze for detection."}
 
 
 
 
 
 
 
 
103
 
104
+ # --- Run the API ---
105
  if __name__ == "__main__":
106
+ uvicorn.run(app, host="0.0.0.0", port=8000)
 
 
 
 
 
 
 
 
 
 
 
requirements.txt CHANGED
@@ -1,20 +1,6 @@
1
- # Core FastAPI and server requirements
2
- fastapi==0.104.1
3
- uvicorn[standard]==0.24.0
4
-
5
- # Machine Learning and Image Processing (optimized)
6
- torch>=2.0.0
7
- torchvision>=0.15.0
8
- Pillow>=10.0.0
9
-
10
- # Lightweight transformers for CLIP only
11
- transformers>=4.30.0
12
-
13
- # HTTP and API client
14
- aiohttp>=3.8.0
15
-
16
- # Data validation and serialization
17
- pydantic>=2.0.0
18
-
19
- # Utilities
20
- python-multipart>=0.0.6
 
1
+ fastapi
2
+ uvicorn
3
+ python-multipart
4
+ pillow
5
+ torch
6
+ transformers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
yolov8n.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f59b3d833e2ff32e194b5bb8e08d211dc7c5bdf144b90d2c8412c47ccfc83b36
3
+ size 6549796