Spaces:
Sleeping
Sleeping
har1zarD
commited on
Commit
·
543a89b
1
Parent(s):
d05a990
DEV
Browse files- .env.example +0 -5
- DEPLOYMENT.md +0 -451
- app.py +401 -1061
- requirements.txt +7 -22
.env.example
CHANGED
|
@@ -1,8 +1,3 @@
|
|
| 1 |
# Server Configuration
|
| 2 |
PORT=8000
|
| 3 |
HOST=0.0.0.0
|
| 4 |
-
|
| 5 |
-
# API Keys (optional, already in code)
|
| 6 |
-
USDA_API_KEY=USDA_API_KEY
|
| 7 |
-
NUTRITIONIX_APP_ID=NUTRITIONIX_APP_ID
|
| 8 |
-
NUTRITIONIX_API_KEY=NUTRITIONIX_API_KEY
|
|
|
|
| 1 |
# Server Configuration
|
| 2 |
PORT=8000
|
| 3 |
HOST=0.0.0.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
DEPLOYMENT.md
DELETED
|
@@ -1,451 +0,0 @@
|
|
| 1 |
-
# 🚀 Food Recognition Backend - Deployment Guide
|
| 2 |
-
|
| 3 |
-
Complete guide for deploying the food recognition API for **FREE** on various platforms.
|
| 4 |
-
|
| 5 |
-
---
|
| 6 |
-
|
| 7 |
-
## 📋 Table of Contents
|
| 8 |
-
1. [Quick Start](#quick-start)
|
| 9 |
-
2. [Free Hosting Options](#free-hosting-options)
|
| 10 |
-
3. [Deployment Instructions](#deployment-instructions)
|
| 11 |
-
4. [Environment Variables](#environment-variables)
|
| 12 |
-
5. [Testing Your Deployment](#testing-your-deployment)
|
| 13 |
-
6. [Integration with Next.js](#integration-with-nextjs)
|
| 14 |
-
|
| 15 |
-
---
|
| 16 |
-
|
| 17 |
-
## 🎯 Quick Start
|
| 18 |
-
|
| 19 |
-
Before deploying, ensure you have:
|
| 20 |
-
- ✅ Python 3.11+
|
| 21 |
-
- ✅ Git repository (GitHub/GitLab)
|
| 22 |
-
- ✅ Docker installed (for local testing)
|
| 23 |
-
|
| 24 |
-
---
|
| 25 |
-
|
| 26 |
-
## 💰 Free Hosting Options
|
| 27 |
-
|
| 28 |
-
### 🥇 **Option 1: Hugging Face Spaces** (RECOMMENDED)
|
| 29 |
-
- **Cost**: 100% FREE
|
| 30 |
-
- **Specs**: 2 vCPU, 16GB RAM
|
| 31 |
-
- **Limits**: No request limits
|
| 32 |
-
- **Cold Starts**: ~30-60s first request
|
| 33 |
-
- **Best For**: ML models, unlimited testing
|
| 34 |
-
|
| 35 |
-
### 🥈 **Option 2: Render**
|
| 36 |
-
- **Cost**: FREE tier available
|
| 37 |
-
- **Specs**: 512MB RAM, shared CPU
|
| 38 |
-
- **Limits**: Spins down after 15min inactivity
|
| 39 |
-
- **Cold Starts**: ~30-60s after sleep
|
| 40 |
-
- **Best For**: Simple APIs with moderate usage
|
| 41 |
-
|
| 42 |
-
### 🥉 **Option 3: Railway** (Limited Free)
|
| 43 |
-
- **Cost**: $5 free credit/month
|
| 44 |
-
- **Specs**: ~500 hours/month
|
| 45 |
-
- **Limits**: Credit-based
|
| 46 |
-
- **Best For**: Development/staging
|
| 47 |
-
|
| 48 |
-
### ⚠️ **NOT Recommended (Too Restrictive)**
|
| 49 |
-
- ❌ Vercel/Netlify - 50MB limit (model is 500MB+)
|
| 50 |
-
- ❌ Heroku - No free tier anymore
|
| 51 |
-
- ❌ AWS Lambda - 250MB deployment limit
|
| 52 |
-
|
| 53 |
-
---
|
| 54 |
-
|
| 55 |
-
## 📦 Deployment Instructions
|
| 56 |
-
|
| 57 |
-
### 🟢 Deploy to Hugging Face Spaces (BEST FREE OPTION)
|
| 58 |
-
|
| 59 |
-
**Step 1: Create Account**
|
| 60 |
-
```bash
|
| 61 |
-
# Visit https://huggingface.co/join
|
| 62 |
-
# Create free account
|
| 63 |
-
```
|
| 64 |
-
|
| 65 |
-
**Step 2: Create New Space**
|
| 66 |
-
1. Go to https://huggingface.co/new-space
|
| 67 |
-
2. **Name**: `food-recognition-api` (or your choice)
|
| 68 |
-
3. **License**: MIT
|
| 69 |
-
4. **SDK**: Docker
|
| 70 |
-
5. **Hardware**: CPU (basic) - FREE ✅
|
| 71 |
-
6. Click **Create Space**
|
| 72 |
-
|
| 73 |
-
**Step 3: Prepare Files**
|
| 74 |
-
|
| 75 |
-
Create `Dockerfile` (already included):
|
| 76 |
-
```dockerfile
|
| 77 |
-
FROM python:3.11-slim
|
| 78 |
-
WORKDIR /app
|
| 79 |
-
RUN apt-get update && apt-get install -y gcc g++ && rm -rf /var/lib/apt/lists/*
|
| 80 |
-
COPY requirements.txt .
|
| 81 |
-
RUN pip install --no-cache-dir -r requirements.txt
|
| 82 |
-
COPY app.py .
|
| 83 |
-
EXPOSE 8000
|
| 84 |
-
ENV PYTHONUNBUFFERED=1
|
| 85 |
-
ENV PORT=8000
|
| 86 |
-
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
|
| 87 |
-
```
|
| 88 |
-
|
| 89 |
-
**Step 4: Push to Space**
|
| 90 |
-
|
| 91 |
-
Option A: Web UI
|
| 92 |
-
```bash
|
| 93 |
-
# Zip your files: app.py, requirements.txt, Dockerfile
|
| 94 |
-
# Upload via Hugging Face Space UI
|
| 95 |
-
```
|
| 96 |
-
|
| 97 |
-
Option B: Git (recommended)
|
| 98 |
-
```bash
|
| 99 |
-
# Clone your space
|
| 100 |
-
git clone https://huggingface.co/spaces/YOUR_USERNAME/food-recognition-api
|
| 101 |
-
cd food-recognition-api
|
| 102 |
-
|
| 103 |
-
# Copy files
|
| 104 |
-
cp /path/to/app.py .
|
| 105 |
-
cp /path/to/requirements.txt .
|
| 106 |
-
cp /path/to/Dockerfile .
|
| 107 |
-
|
| 108 |
-
# Commit and push
|
| 109 |
-
git add .
|
| 110 |
-
git commit -m "Initial deployment"
|
| 111 |
-
git push
|
| 112 |
-
```
|
| 113 |
-
|
| 114 |
-
**Step 5: Configure Environment**
|
| 115 |
-
1. Go to Space Settings → Variables
|
| 116 |
-
2. Add:
|
| 117 |
-
```
|
| 118 |
-
PORT=7860
|
| 119 |
-
HOST=0.0.0.0
|
| 120 |
-
```
|
| 121 |
-
|
| 122 |
-
**Step 6: Get Your API URL**
|
| 123 |
-
```
|
| 124 |
-
https://YOUR_USERNAME-food-recognition-api.hf.space
|
| 125 |
-
```
|
| 126 |
-
|
| 127 |
-
**Build Time**: 5-10 minutes (PyTorch is large)
|
| 128 |
-
|
| 129 |
-
---
|
| 130 |
-
|
| 131 |
-
### 🟡 Deploy to Render
|
| 132 |
-
|
| 133 |
-
**Step 1: Create Account**
|
| 134 |
-
- Visit https://render.com
|
| 135 |
-
- Sign up with GitHub
|
| 136 |
-
|
| 137 |
-
**Step 2: Create New Web Service**
|
| 138 |
-
1. Click **New +** → **Web Service**
|
| 139 |
-
2. Connect your GitHub repository
|
| 140 |
-
3. Settings:
|
| 141 |
-
- **Name**: `food-recognition-api`
|
| 142 |
-
- **Environment**: Docker
|
| 143 |
-
- **Region**: Choose closest
|
| 144 |
-
- **Branch**: `main`
|
| 145 |
-
- **Dockerfile Path**: `./Dockerfile`
|
| 146 |
-
|
| 147 |
-
**Step 3: Configure**
|
| 148 |
-
- **Plan**: Free
|
| 149 |
-
- **Environment Variables**:
|
| 150 |
-
```
|
| 151 |
-
PORT=10000
|
| 152 |
-
USDA_API_KEY=your_key_here
|
| 153 |
-
NUTRITIONIX_APP_ID=your_id_here
|
| 154 |
-
NUTRITIONIX_API_KEY=your_key_here
|
| 155 |
-
```
|
| 156 |
-
|
| 157 |
-
**Step 4: Deploy**
|
| 158 |
-
- Click **Create Web Service**
|
| 159 |
-
- Wait 10-15 minutes for build
|
| 160 |
-
|
| 161 |
-
**Your URL**: `https://food-recognition-api.onrender.com`
|
| 162 |
-
|
| 163 |
-
⚠️ **Note**: Free tier sleeps after 15min inactivity. First request after sleep takes ~30-60s.
|
| 164 |
-
|
| 165 |
-
---
|
| 166 |
-
|
| 167 |
-
### 🟠 Deploy to Railway (Limited Free)
|
| 168 |
-
|
| 169 |
-
**Step 1: Create Account**
|
| 170 |
-
- Visit https://railway.app
|
| 171 |
-
- Sign up with GitHub
|
| 172 |
-
|
| 173 |
-
**Step 2: Create New Project**
|
| 174 |
-
1. Click **New Project**
|
| 175 |
-
2. Select **Deploy from GitHub repo**
|
| 176 |
-
3. Choose your repository
|
| 177 |
-
|
| 178 |
-
**Step 3: Configure Service**
|
| 179 |
-
1. Click your service
|
| 180 |
-
2. Settings:
|
| 181 |
-
- **Root Directory**: `/` (or `/food_recognition_backend` if nested)
|
| 182 |
-
- **Custom Start Command**: Leave empty (uses Dockerfile)
|
| 183 |
-
|
| 184 |
-
**Step 4: Environment Variables**
|
| 185 |
-
```
|
| 186 |
-
PORT=8000
|
| 187 |
-
USDA_API_KEY=your_key_here
|
| 188 |
-
NUTRITIONIX_APP_ID=your_id_here
|
| 189 |
-
NUTRITIONIX_API_KEY=your_key_here
|
| 190 |
-
```
|
| 191 |
-
|
| 192 |
-
**Step 5: Generate Domain**
|
| 193 |
-
- Settings → Networking → Generate Domain
|
| 194 |
-
|
| 195 |
-
**Your URL**: `https://food-recognition-api-production.up.railway.app`
|
| 196 |
-
|
| 197 |
-
💰 **Cost**: $5 free credit monthly (~500 hours)
|
| 198 |
-
|
| 199 |
-
---
|
| 200 |
-
|
| 201 |
-
## 🔐 Environment Variables
|
| 202 |
-
|
| 203 |
-
### Required Variables
|
| 204 |
-
|
| 205 |
-
```bash
|
| 206 |
-
# Server Configuration
|
| 207 |
-
PORT=8000 # Port for the API (auto-assigned by some hosts)
|
| 208 |
-
HOST=0.0.0.0 # Host binding
|
| 209 |
-
|
| 210 |
-
# Optional: Nutrition API Keys (already have defaults)
|
| 211 |
-
USDA_API_KEY=your_key_here
|
| 212 |
-
NUTRITIONIX_APP_ID=your_id_here
|
| 213 |
-
NUTRITIONIX_API_KEY=your_key_here
|
| 214 |
-
```
|
| 215 |
-
|
| 216 |
-
### Where to Set Variables
|
| 217 |
-
|
| 218 |
-
**Hugging Face Spaces:**
|
| 219 |
-
- Settings → Repository secrets
|
| 220 |
-
|
| 221 |
-
**Render:**
|
| 222 |
-
- Environment → Environment Variables
|
| 223 |
-
|
| 224 |
-
**Railway:**
|
| 225 |
-
- Variables tab
|
| 226 |
-
|
| 227 |
-
---
|
| 228 |
-
|
| 229 |
-
## 🧪 Testing Your Deployment
|
| 230 |
-
|
| 231 |
-
### 1. Health Check
|
| 232 |
-
```bash
|
| 233 |
-
curl https://YOUR_API_URL/health
|
| 234 |
-
```
|
| 235 |
-
|
| 236 |
-
Expected response:
|
| 237 |
-
```json
|
| 238 |
-
{
|
| 239 |
-
"status": "healthy",
|
| 240 |
-
"model_loaded": true,
|
| 241 |
-
"device": "cpu",
|
| 242 |
-
"food_pipeline_loaded": true,
|
| 243 |
-
"model_type": "Professional Food Recognition Models"
|
| 244 |
-
}
|
| 245 |
-
```
|
| 246 |
-
|
| 247 |
-
### 2. Test Food Recognition
|
| 248 |
-
```bash
|
| 249 |
-
# Upload image
|
| 250 |
-
curl -X POST https://YOUR_API_URL/analyze?top_alternatives=3 \
|
| 251 |
-
-F "file=@path/to/food_image.jpg"
|
| 252 |
-
```
|
| 253 |
-
|
| 254 |
-
Expected response:
|
| 255 |
-
```json
|
| 256 |
-
{
|
| 257 |
-
"label": "pizza",
|
| 258 |
-
"confidence": 0.95,
|
| 259 |
-
"nutrition": {
|
| 260 |
-
"calories": 266,
|
| 261 |
-
"protein": 11.0,
|
| 262 |
-
"fat": 10.0,
|
| 263 |
-
"carbs": 33.0,
|
| 264 |
-
"fiber": 2.3,
|
| 265 |
-
"sugar": 3.7,
|
| 266 |
-
"sodium": 598
|
| 267 |
-
},
|
| 268 |
-
"alternatives": ["flatbread", "focaccia"],
|
| 269 |
-
"source": "Open Food Facts"
|
| 270 |
-
}
|
| 271 |
-
```
|
| 272 |
-
|
| 273 |
-
### 3. Test from URL
|
| 274 |
-
```bash
|
| 275 |
-
curl -X POST "https://YOUR_API_URL/analyze-url?image_url=https://example.com/food.jpg&top_alternatives=3"
|
| 276 |
-
```
|
| 277 |
-
|
| 278 |
-
### 4. Search Nutrition Only
|
| 279 |
-
```bash
|
| 280 |
-
curl https://YOUR_API_URL/search-nutrition/pizza
|
| 281 |
-
```
|
| 282 |
-
|
| 283 |
-
---
|
| 284 |
-
|
| 285 |
-
## 🔗 Integration with Next.js
|
| 286 |
-
|
| 287 |
-
### Step 1: Update Environment Variables
|
| 288 |
-
|
| 289 |
-
In your Next.js project, add to `.env`:
|
| 290 |
-
|
| 291 |
-
```bash
|
| 292 |
-
# Production Food Recognition API
|
| 293 |
-
FOOD_RECOGNITION_API_URL=https://YOUR_API_URL
|
| 294 |
-
```
|
| 295 |
-
|
| 296 |
-
### Step 2: Update API Routes
|
| 297 |
-
|
| 298 |
-
Your Next.js API routes are already configured to use this variable:
|
| 299 |
-
|
| 300 |
-
```javascript
|
| 301 |
-
// src/app/api/nutrition/analyze-food/route.js
|
| 302 |
-
const FOOD_API_BASE_URL = process.env.FOOD_RECOGNITION_API_URL || "http://localhost:8000";
|
| 303 |
-
```
|
| 304 |
-
|
| 305 |
-
### Step 3: Deploy Next.js
|
| 306 |
-
|
| 307 |
-
**On Vercel/Coolify:**
|
| 308 |
-
1. Add environment variable:
|
| 309 |
-
```
|
| 310 |
-
FOOD_RECOGNITION_API_URL=https://YOUR_USERNAME-food-recognition-api.hf.space
|
| 311 |
-
```
|
| 312 |
-
2. Deploy/Restart
|
| 313 |
-
|
| 314 |
-
### Step 4: Test Integration
|
| 315 |
-
|
| 316 |
-
From your Next.js app:
|
| 317 |
-
```javascript
|
| 318 |
-
const formData = new FormData();
|
| 319 |
-
formData.append('file', imageFile);
|
| 320 |
-
|
| 321 |
-
const response = await fetch('/api/nutrition/analyze-food', {
|
| 322 |
-
method: 'POST',
|
| 323 |
-
body: formData,
|
| 324 |
-
});
|
| 325 |
-
|
| 326 |
-
const result = await response.json();
|
| 327 |
-
console.log(result.data.foodName); // "pizza"
|
| 328 |
-
console.log(result.data.calories); // 266
|
| 329 |
-
```
|
| 330 |
-
|
| 331 |
-
---
|
| 332 |
-
|
| 333 |
-
## ⚡ Performance Tips
|
| 334 |
-
|
| 335 |
-
### 1. Reduce Cold Starts
|
| 336 |
-
**Hugging Face Spaces:**
|
| 337 |
-
- Upgrade to paid tier for always-on ($9/month) - optional
|
| 338 |
-
|
| 339 |
-
**Render:**
|
| 340 |
-
- Paid plan keeps service always on ($7/month) - optional
|
| 341 |
-
- Free: Keep pinging `/health` every 10 minutes
|
| 342 |
-
|
| 343 |
-
### 2. Implement Caching
|
| 344 |
-
In Next.js, cache results:
|
| 345 |
-
```javascript
|
| 346 |
-
// Example with Redis/Upstash
|
| 347 |
-
const cacheKey = `food_${imageHash}`;
|
| 348 |
-
const cached = await redis.get(cacheKey);
|
| 349 |
-
if (cached) return cached;
|
| 350 |
-
|
| 351 |
-
// Call API only if not cached
|
| 352 |
-
const result = await callFoodAPI();
|
| 353 |
-
await redis.set(cacheKey, result, { ex: 86400 }); // 24h cache
|
| 354 |
-
```
|
| 355 |
-
|
| 356 |
-
### 3. Optimize Image Size
|
| 357 |
-
Before sending to API:
|
| 358 |
-
```javascript
|
| 359 |
-
// Resize images to max 800x800px
|
| 360 |
-
const resized = await sharp(imageBuffer)
|
| 361 |
-
.resize(800, 800, { fit: 'inside' })
|
| 362 |
-
.jpeg({ quality: 80 })
|
| 363 |
-
.toBuffer();
|
| 364 |
-
```
|
| 365 |
-
|
| 366 |
-
---
|
| 367 |
-
|
| 368 |
-
## 🐛 Troubleshooting
|
| 369 |
-
|
| 370 |
-
### Build Fails - Out of Memory
|
| 371 |
-
**Solution**: Reduce PyTorch size in `requirements.txt`:
|
| 372 |
-
```txt
|
| 373 |
-
torch>=2.0.0,<2.2.0 # Pin specific version
|
| 374 |
-
```
|
| 375 |
-
|
| 376 |
-
### API Timeout
|
| 377 |
-
**Solution**: Increase timeout in Next.js:
|
| 378 |
-
```javascript
|
| 379 |
-
const response = await fetch(API_URL, {
|
| 380 |
-
method: 'POST',
|
| 381 |
-
body: formData,
|
| 382 |
-
signal: AbortSignal.timeout(30000), // 30s timeout
|
| 383 |
-
});
|
| 384 |
-
```
|
| 385 |
-
|
| 386 |
-
### Model Not Loading
|
| 387 |
-
**Solution**: Check logs for memory issues. Upgrade to paid tier or reduce model size.
|
| 388 |
-
|
| 389 |
-
### 422 Error - No Nutrition Data
|
| 390 |
-
**Solution**: This is expected for some foods. Implement fallback:
|
| 391 |
-
```javascript
|
| 392 |
-
if (response.status === 422) {
|
| 393 |
-
// Show manual input form
|
| 394 |
-
showManualInputForm();
|
| 395 |
-
}
|
| 396 |
-
```
|
| 397 |
-
|
| 398 |
-
---
|
| 399 |
-
|
| 400 |
-
## 📊 Cost Comparison
|
| 401 |
-
|
| 402 |
-
| Platform | Free Tier | Monthly Cost | RAM | Cold Start | Best For |
|
| 403 |
-
|----------|-----------|--------------|-----|------------|----------|
|
| 404 |
-
| **Hugging Face** | ✅ Unlimited | $0 | 16GB | ~30-60s | **Development & Production** |
|
| 405 |
-
| **Render** | ✅ Yes | $0 | 512MB | ~30-60s | **Light Usage** |
|
| 406 |
-
| **Railway** | ⚠️ Limited | $0 ($5 credit) | 2GB | None | **Testing** |
|
| 407 |
-
| **Coolify** | ✅ Self-hosted | $0 (your server) | Custom | None | **Full Control** |
|
| 408 |
-
|
| 409 |
-
---
|
| 410 |
-
|
| 411 |
-
## 🎯 Recommendation
|
| 412 |
-
|
| 413 |
-
**For Production (Free):**
|
| 414 |
-
1. 🥇 **Hugging Face Spaces** - Best free option, no limits
|
| 415 |
-
2. 🥈 **Render** - Good if traffic is low (sleeps after 15min)
|
| 416 |
-
|
| 417 |
-
**For Production (Paid):**
|
| 418 |
-
1. 🥇 **Coolify** (Self-hosted) - Full control, $5-20/month
|
| 419 |
-
2. 🥈 **Railway Pro** - Easy, $20/month
|
| 420 |
-
3. 🥉 **Render Paid** - Simple, $7/month
|
| 421 |
-
|
| 422 |
-
---
|
| 423 |
-
|
| 424 |
-
## 📝 Next Steps
|
| 425 |
-
|
| 426 |
-
1. ✅ Choose hosting platform (Hugging Face recommended)
|
| 427 |
-
2. ✅ Deploy using instructions above
|
| 428 |
-
3. ✅ Test with `/health` endpoint
|
| 429 |
-
4. ✅ Update `FOOD_RECOGNITION_API_URL` in Next.js
|
| 430 |
-
5. ✅ Deploy Next.js with new env variable
|
| 431 |
-
6. ✅ Test end-to-end integration
|
| 432 |
-
|
| 433 |
-
---
|
| 434 |
-
|
| 435 |
-
## 🆘 Support
|
| 436 |
-
|
| 437 |
-
If you encounter issues:
|
| 438 |
-
1. Check logs on your hosting platform
|
| 439 |
-
2. Test locally with Docker first
|
| 440 |
-
3. Verify environment variables are set
|
| 441 |
-
4. Check API URL is accessible
|
| 442 |
-
|
| 443 |
-
---
|
| 444 |
-
|
| 445 |
-
## 📄 License
|
| 446 |
-
|
| 447 |
-
MIT License - Free to use for personal and commercial projects.
|
| 448 |
-
|
| 449 |
-
---
|
| 450 |
-
|
| 451 |
-
**Ready to deploy? Start with Hugging Face Spaces for the best free experience!** 🚀
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app.py
CHANGED
|
@@ -1,770 +1,245 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
-
|
| 4 |
-
|
| 5 |
|
| 6 |
-
|
| 7 |
|
| 8 |
-
Ključne
|
| 9 |
-
-
|
| 10 |
-
-
|
| 11 |
-
-
|
| 12 |
-
- 📊
|
| 13 |
-
-
|
| 14 |
-
-
|
| 15 |
-
- 🧠 Smart fallback logika
|
| 16 |
|
| 17 |
Autor: AI Assistant
|
| 18 |
-
Verzija:
|
| 19 |
"""
|
| 20 |
|
| 21 |
import os
|
| 22 |
-
import io
|
| 23 |
-
from io import BytesIO
|
| 24 |
-
from typing import Optional, Dict, Any, List, Tuple
|
| 25 |
-
import base64
|
| 26 |
-
import re
|
| 27 |
-
import requests
|
| 28 |
-
import contextlib
|
| 29 |
import logging
|
| 30 |
-
from
|
| 31 |
-
import
|
| 32 |
|
| 33 |
import uvicorn
|
| 34 |
-
from fastapi import FastAPI, File, UploadFile, HTTPException
|
| 35 |
from fastapi.responses import JSONResponse
|
| 36 |
from fastapi.middleware.cors import CORSMiddleware
|
| 37 |
|
| 38 |
# Image processing
|
| 39 |
-
from PIL import Image
|
| 40 |
-
import numpy as np
|
| 41 |
-
import albumentations as A
|
| 42 |
-
|
| 43 |
-
# Deep learning
|
| 44 |
import torch
|
| 45 |
-
import
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
AutoImageProcessor, AutoModelForImageClassification
|
| 50 |
-
)
|
| 51 |
-
import timm
|
| 52 |
-
from sklearn.ensemble import VotingClassifier
|
| 53 |
-
from scipy.special import softmax
|
| 54 |
|
| 55 |
# Setup logging
|
| 56 |
logging.basicConfig(level=logging.INFO)
|
| 57 |
logger = logging.getLogger(__name__)
|
| 58 |
|
| 59 |
-
# ---
|
| 60 |
-
#
|
| 61 |
-
FOOD_MODELS = {
|
| 62 |
-
"primary": "Kaludi/food-category-classification-v2.0", # Specijalizovani food model
|
| 63 |
-
"secondary": "nateraw/food", # Backup food model
|
| 64 |
-
"tertiary": "microsoft/resnet-50", # General vision model za fallback
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
# CLIP za non-food detection i fallback
|
| 68 |
CLIP_MODEL_NAME = "openai/clip-vit-large-patch14"
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
"
|
| 78 |
-
"
|
| 79 |
-
"
|
| 80 |
-
"
|
| 81 |
-
"
|
| 82 |
-
"
|
| 83 |
-
"
|
| 84 |
-
"
|
| 85 |
-
"
|
| 86 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
]
|
| 88 |
|
| 89 |
-
|
| 90 |
def select_device() -> str:
|
| 91 |
-
"""Odabire najbolji dostupni uređaj
|
| 92 |
if torch.cuda.is_available():
|
| 93 |
return "cuda"
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
return "mps"
|
| 97 |
-
except Exception:
|
| 98 |
-
pass
|
| 99 |
return "cpu"
|
| 100 |
|
| 101 |
-
def select_dtype(device: str):
|
| 102 |
-
"""Odabire optimalni dtype za dati uređaj."""
|
| 103 |
-
if device == "cuda":
|
| 104 |
-
return torch.float16
|
| 105 |
-
if device == "mps":
|
| 106 |
-
return torch.float16
|
| 107 |
-
return torch.float32
|
| 108 |
-
|
| 109 |
-
def autocast_context(device: str, dtype):
|
| 110 |
-
"""Vraća odgovarajući autocast kontekst."""
|
| 111 |
-
if device in ("cuda", "cpu", "mps"):
|
| 112 |
-
try:
|
| 113 |
-
return torch.autocast(device_type=device, dtype=dtype)
|
| 114 |
-
except Exception:
|
| 115 |
-
return contextlib.nullcontext()
|
| 116 |
-
return contextlib.nullcontext()
|
| 117 |
|
| 118 |
-
|
| 119 |
-
"""
|
| 120 |
-
Vraća optimizovane Food-101 labele sa sinonimima i varijantama.
|
| 121 |
-
Ovo pomaže u boljem mapiranju rezultata modela.
|
| 122 |
"""
|
| 123 |
-
|
| 124 |
-
"apple pie": ["apple pie", "apple tart", "apple dessert"],
|
| 125 |
-
"baby back ribs": ["baby back ribs", "pork ribs", "barbecue ribs", "bbq ribs"],
|
| 126 |
-
"baklava": ["baklava", "phyllo pastry", "honey pastry"],
|
| 127 |
-
"beef carpaccio": ["beef carpaccio", "raw beef", "carpaccio"],
|
| 128 |
-
"beef tartare": ["beef tartare", "steak tartare", "raw beef"],
|
| 129 |
-
"beet salad": ["beet salad", "beetroot salad", "beet"],
|
| 130 |
-
"beignets": ["beignets", "donut", "fried dough"],
|
| 131 |
-
"bibimbap": ["bibimbap", "korean rice bowl", "mixed rice"],
|
| 132 |
-
"bread pudding": ["bread pudding", "pudding"],
|
| 133 |
-
"breakfast burrito": ["breakfast burrito", "burrito", "wrap"],
|
| 134 |
-
"bruschetta": ["bruschetta", "toast", "bread"],
|
| 135 |
-
"caesar salad": ["caesar salad", "salad", "lettuce"],
|
| 136 |
-
"cannoli": ["cannoli", "italian pastry", "pastry"],
|
| 137 |
-
"caprese salad": ["caprese salad", "mozzarella tomato", "salad"],
|
| 138 |
-
"carrot cake": ["carrot cake", "cake", "dessert"],
|
| 139 |
-
"ceviche": ["ceviche", "raw fish", "seafood"],
|
| 140 |
-
"cheesecake": ["cheesecake", "cake", "dessert"],
|
| 141 |
-
"cheese plate": ["cheese plate", "cheese", "cheese board"],
|
| 142 |
-
"chicken curry": ["chicken curry", "curry", "chicken"],
|
| 143 |
-
"chicken quesadilla": ["chicken quesadilla", "quesadilla", "tortilla"],
|
| 144 |
-
"chicken wings": ["chicken wings", "wings", "chicken"],
|
| 145 |
-
"chocolate cake": ["chocolate cake", "cake", "chocolate dessert"],
|
| 146 |
-
"chocolate mousse": ["chocolate mousse", "mousse", "chocolate dessert"],
|
| 147 |
-
"churros": ["churros", "fried dough", "spanish pastry"],
|
| 148 |
-
"clam chowder": ["clam chowder", "soup", "seafood soup"],
|
| 149 |
-
"club sandwich": ["club sandwich", "sandwich"],
|
| 150 |
-
"crab cakes": ["crab cakes", "crab", "seafood"],
|
| 151 |
-
"creme brulee": ["creme brulee", "custard", "dessert"],
|
| 152 |
-
"croque madame": ["croque madame", "sandwich", "french sandwich"],
|
| 153 |
-
"cup cakes": ["cupcakes", "muffin", "small cake"],
|
| 154 |
-
"deviled eggs": ["deviled eggs", "eggs", "egg"],
|
| 155 |
-
"donuts": ["donuts", "donut", "doughnut"],
|
| 156 |
-
"dumplings": ["dumplings", "dumpling", "steamed bun"],
|
| 157 |
-
"edamame": ["edamame", "soybean", "beans"],
|
| 158 |
-
"eggs benedict": ["eggs benedict", "eggs", "poached eggs"],
|
| 159 |
-
"escargots": ["escargots", "snails", "french appetizer"],
|
| 160 |
-
"falafel": ["falafel", "chickpea", "middle eastern"],
|
| 161 |
-
"filet mignon": ["filet mignon", "steak", "beef"],
|
| 162 |
-
"fish and chips": ["fish and chips", "fried fish", "fish"],
|
| 163 |
-
"foie gras": ["foie gras", "liver", "pate"],
|
| 164 |
-
"french fries": ["french fries", "fries", "potato", "chips"],
|
| 165 |
-
"french onion soup": ["french onion soup", "onion soup", "soup"],
|
| 166 |
-
"french toast": ["french toast", "toast", "bread"],
|
| 167 |
-
"fried calamari": ["fried calamari", "calamari", "squid", "seafood"],
|
| 168 |
-
"fried rice": ["fried rice", "rice", "asian rice"],
|
| 169 |
-
"frozen yogurt": ["frozen yogurt", "yogurt", "ice cream"],
|
| 170 |
-
"garlic bread": ["garlic bread", "bread", "toast"],
|
| 171 |
-
"gnocchi": ["gnocchi", "pasta", "potato pasta"],
|
| 172 |
-
"greek salad": ["greek salad", "salad", "mediterranean salad"],
|
| 173 |
-
"grilled cheese sandwich": ["grilled cheese", "cheese sandwich", "sandwich"],
|
| 174 |
-
"grilled salmon": ["grilled salmon", "salmon", "fish"],
|
| 175 |
-
"guacamole": ["guacamole", "avocado", "dip"],
|
| 176 |
-
"gyoza": ["gyoza", "dumpling", "potsticker"],
|
| 177 |
-
"hamburger": ["hamburger", "burger", "cheeseburger"],
|
| 178 |
-
"hot and sour soup": ["hot and sour soup", "soup", "asian soup"],
|
| 179 |
-
"hot dog": ["hot dog", "sausage", "frankfurter"],
|
| 180 |
-
"huevos rancheros": ["huevos rancheros", "eggs", "mexican eggs"],
|
| 181 |
-
"hummus": ["hummus", "chickpea dip", "dip"],
|
| 182 |
-
"ice cream": ["ice cream", "gelato", "frozen dessert"],
|
| 183 |
-
"lasagna": ["lasagna", "pasta", "italian pasta"],
|
| 184 |
-
"lobster bisque": ["lobster bisque", "soup", "seafood soup"],
|
| 185 |
-
"lobster roll sandwich": ["lobster roll", "lobster sandwich", "seafood"],
|
| 186 |
-
"macaroni and cheese": ["mac and cheese", "macaroni", "pasta"],
|
| 187 |
-
"macarons": ["macarons", "macaron", "french cookie"],
|
| 188 |
-
"miso soup": ["miso soup", "soup", "japanese soup"],
|
| 189 |
-
"mussels": ["mussels", "shellfish", "seafood"],
|
| 190 |
-
"nachos": ["nachos", "chips", "tortilla chips"],
|
| 191 |
-
"omelette": ["omelette", "omelet", "eggs"],
|
| 192 |
-
"onion rings": ["onion rings", "fried onion", "onion"],
|
| 193 |
-
"oysters": ["oysters", "shellfish", "seafood"],
|
| 194 |
-
"pad thai": ["pad thai", "thai noodles", "noodles"],
|
| 195 |
-
"paella": ["paella", "spanish rice", "rice"],
|
| 196 |
-
"pancakes": ["pancakes", "pancake", "breakfast"],
|
| 197 |
-
"panna cotta": ["panna cotta", "dessert", "custard"],
|
| 198 |
-
"peking duck": ["peking duck", "duck", "chinese duck"],
|
| 199 |
-
"pho": ["pho", "vietnamese soup", "noodle soup"],
|
| 200 |
-
"pizza": ["pizza", "italian pizza", "pie"],
|
| 201 |
-
"pork chop": ["pork chop", "pork", "meat"],
|
| 202 |
-
"poutine": ["poutine", "fries", "canadian fries"],
|
| 203 |
-
"prime rib": ["prime rib", "beef", "roast beef"],
|
| 204 |
-
"pulled pork sandwich": ["pulled pork", "pork sandwich", "sandwich"],
|
| 205 |
-
"ramen": ["ramen", "noodles", "japanese noodles"],
|
| 206 |
-
"ravioli": ["ravioli", "pasta", "stuffed pasta"],
|
| 207 |
-
"red velvet cake": ["red velvet cake", "cake", "red cake"],
|
| 208 |
-
"risotto": ["risotto", "rice", "italian rice"],
|
| 209 |
-
"samosa": ["samosa", "indian pastry", "fried pastry"],
|
| 210 |
-
"sashimi": ["sashimi", "raw fish", "japanese fish"],
|
| 211 |
-
"scallops": ["scallops", "shellfish", "seafood"],
|
| 212 |
-
"seaweed salad": ["seaweed salad", "seaweed", "salad"],
|
| 213 |
-
"shrimp and grits": ["shrimp and grits", "shrimp", "grits"],
|
| 214 |
-
"spaghetti bolognese": ["spaghetti bolognese", "pasta", "spaghetti"],
|
| 215 |
-
"spaghetti carbonara": ["spaghetti carbonara", "pasta", "carbonara"],
|
| 216 |
-
"spring rolls": ["spring rolls", "rolls", "vietnamese rolls"],
|
| 217 |
-
"steak": ["steak", "beef", "grilled beef"],
|
| 218 |
-
"strawberry shortcake": ["strawberry shortcake", "shortcake", "strawberry cake"],
|
| 219 |
-
"sushi": ["sushi", "japanese food", "raw fish"],
|
| 220 |
-
"tacos": ["tacos", "taco", "mexican food"],
|
| 221 |
-
"takoyaki": ["takoyaki", "octopus balls", "japanese snack"],
|
| 222 |
-
"tiramisu": ["tiramisu", "italian dessert", "coffee dessert"],
|
| 223 |
-
"tuna tartare": ["tuna tartare", "raw tuna", "tuna"],
|
| 224 |
-
"waffles": ["waffles", "waffle", "breakfast"]
|
| 225 |
-
}
|
| 226 |
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
"""
|
| 231 |
-
Napredni image preprocessing koji generiše multiple varijante slike
|
| 232 |
-
za bolju preciznost ensemble modela.
|
| 233 |
-
"""
|
| 234 |
-
# Konvertuj u RGB ako nije
|
| 235 |
-
if image.mode != "RGB":
|
| 236 |
-
image = image.convert("RGB")
|
| 237 |
-
|
| 238 |
-
# Lista preprocessovanih slika
|
| 239 |
-
processed_images = []
|
| 240 |
-
|
| 241 |
-
# 1. Originalna slika (resize)
|
| 242 |
-
original = image.resize((224, 224), Image.Resampling.LANCZOS)
|
| 243 |
-
processed_images.append(original)
|
| 244 |
|
| 245 |
-
|
| 246 |
-
enhancer = ImageEnhance.Contrast(original)
|
| 247 |
-
enhanced = enhancer.enhance(1.2)
|
| 248 |
-
processed_images.append(enhanced)
|
| 249 |
-
|
| 250 |
-
# 3. Enhanced brightness
|
| 251 |
-
enhancer = ImageEnhance.Brightness(original)
|
| 252 |
-
brightened = enhancer.enhance(1.1)
|
| 253 |
-
processed_images.append(brightened)
|
| 254 |
-
|
| 255 |
-
# 4. Sharpened
|
| 256 |
-
sharpened = original.filter(ImageFilter.SHARPEN)
|
| 257 |
-
processed_images.append(sharpened)
|
| 258 |
-
|
| 259 |
-
# 5. Center crop (fokus na centar)
|
| 260 |
-
width, height = original.size
|
| 261 |
-
crop_size = min(width, height)
|
| 262 |
-
left = (width - crop_size) // 2
|
| 263 |
-
top = (height - crop_size) // 2
|
| 264 |
-
right = left + crop_size
|
| 265 |
-
bottom = top + crop_size
|
| 266 |
-
center_cropped = original.crop((left, top, right, bottom)).resize((224, 224))
|
| 267 |
-
processed_images.append(center_cropped)
|
| 268 |
-
|
| 269 |
-
return processed_images
|
| 270 |
-
|
| 271 |
-
def is_non_food_object(text: str) -> bool:
|
| 272 |
-
"""Proverava da li je objekat non-food na osnovu ključnih reči."""
|
| 273 |
-
text_lower = text.lower()
|
| 274 |
-
return any(keyword in text_lower for keyword in NON_FOOD_KEYWORDS)
|
| 275 |
-
|
| 276 |
-
class UltraFoodClassifier:
|
| 277 |
-
"""
|
| 278 |
-
Ultra-optimizovani food classifier sa ensemble pristupom.
|
| 279 |
-
Kombinuje više specijalizovanih modela za maksimalnu preciznost.
|
| 280 |
-
"""
|
| 281 |
-
|
| 282 |
-
def __init__(self, device: str, dtype):
|
| 283 |
self.device = device
|
| 284 |
-
|
| 285 |
-
self.models = {}
|
| 286 |
-
self.processors = {}
|
| 287 |
-
self.clip_model = None
|
| 288 |
-
self.clip_processor = None
|
| 289 |
-
self.food_labels = get_optimized_food101_labels()
|
| 290 |
-
self.label_list = list(self.food_labels.keys())
|
| 291 |
-
|
| 292 |
-
# Load modeli
|
| 293 |
-
self._load_models()
|
| 294 |
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
|
|
|
| 298 |
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
FOOD_MODELS["primary"],
|
| 305 |
-
torch_dtype=self.dtype
|
| 306 |
-
).to(self.device)
|
| 307 |
-
self.models["primary"].eval()
|
| 308 |
-
logger.info("✅ Primary model loaded successfully!")
|
| 309 |
-
except Exception as e:
|
| 310 |
-
logger.warning(f"⚠️ Primary model failed to load: {e}")
|
| 311 |
-
|
| 312 |
-
# 2. Secondary food model
|
| 313 |
-
try:
|
| 314 |
-
logger.info(f"Loading secondary model: {FOOD_MODELS['secondary']}")
|
| 315 |
-
self.models["secondary"] = hf_pipeline(
|
| 316 |
-
"image-classification",
|
| 317 |
-
model=FOOD_MODELS["secondary"],
|
| 318 |
-
device=0 if self.device in ("cuda", "mps") else -1,
|
| 319 |
-
torch_dtype=self.dtype
|
| 320 |
-
)
|
| 321 |
-
logger.info("✅ Secondary model loaded successfully!")
|
| 322 |
-
except Exception as e:
|
| 323 |
-
logger.warning(f"⚠️ Secondary model failed to load: {e}")
|
| 324 |
-
|
| 325 |
-
# 3. CLIP za non-food detection i fallback
|
| 326 |
-
try:
|
| 327 |
-
logger.info(f"Loading CLIP model: {CLIP_MODEL_NAME}")
|
| 328 |
-
self.clip_processor = CLIPProcessor.from_pretrained(CLIP_MODEL_NAME)
|
| 329 |
-
self.clip_model = CLIPModel.from_pretrained(
|
| 330 |
-
CLIP_MODEL_NAME,
|
| 331 |
-
torch_dtype=self.dtype
|
| 332 |
-
).to(self.device)
|
| 333 |
-
self.clip_model.eval()
|
| 334 |
-
logger.info("✅ CLIP model loaded successfully!")
|
| 335 |
-
except Exception as e:
|
| 336 |
-
logger.warning(f"⚠️ CLIP model failed to load: {e}")
|
| 337 |
-
|
| 338 |
-
# Precompute CLIP text embeddings za food labele
|
| 339 |
-
if self.clip_model and self.clip_processor:
|
| 340 |
-
self._precompute_clip_embeddings()
|
| 341 |
-
|
| 342 |
-
def _precompute_clip_embeddings(self):
|
| 343 |
-
"""Precompute CLIP text embeddings za sve food labele."""
|
| 344 |
-
logger.info("🔄 Precomputing CLIP text embeddings...")
|
| 345 |
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
# Dodaj glavni label
|
| 350 |
-
text_prompts.append(f"a photo of {label}")
|
| 351 |
-
# Dodaj sinonime
|
| 352 |
-
for synonym in synonyms[:2]: # Uzmi prva 2 sinonima
|
| 353 |
-
text_prompts.append(f"a photo of {synonym}")
|
| 354 |
-
|
| 355 |
-
# Compute embeddings
|
| 356 |
-
with torch.no_grad():
|
| 357 |
-
text_inputs = self.clip_processor(
|
| 358 |
-
text=text_prompts,
|
| 359 |
-
return_tensors="pt",
|
| 360 |
-
padding=True,
|
| 361 |
-
truncation=True
|
| 362 |
-
)
|
| 363 |
-
text_inputs = {k: v.to(self.device) for k, v in text_inputs.items()}
|
| 364 |
-
|
| 365 |
-
with autocast_context(self.device, self.dtype):
|
| 366 |
-
self.text_embeddings = self.clip_model.get_text_features(**text_inputs)
|
| 367 |
-
self.text_embeddings = self.text_embeddings / self.text_embeddings.norm(dim=-1, keepdim=True)
|
| 368 |
-
|
| 369 |
-
self.text_prompts = text_prompts
|
| 370 |
-
logger.info("✅ CLIP embeddings precomputed!")
|
| 371 |
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
Detektuje da li slika sadrži non-food objekte koristeći CLIP.
|
| 375 |
-
Vraća (is_non_food, confidence).
|
| 376 |
"""
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
# Non-food prompts
|
| 381 |
-
non_food_prompts = [
|
| 382 |
-
"a photo of a bottle",
|
| 383 |
-
"a photo of water",
|
| 384 |
-
"a photo of a drink",
|
| 385 |
-
"a photo of a person",
|
| 386 |
-
"a photo of hands",
|
| 387 |
-
"a photo of a plate",
|
| 388 |
-
"a photo of a table",
|
| 389 |
-
"a photo of utensils",
|
| 390 |
-
"a photo of a background",
|
| 391 |
-
"a photo of furniture",
|
| 392 |
-
"a photo of electronics"
|
| 393 |
-
]
|
| 394 |
|
| 395 |
-
#
|
| 396 |
-
|
| 397 |
-
"a photo of food",
|
| 398 |
-
"a photo of a meal",
|
| 399 |
-
"a photo of something edible",
|
| 400 |
-
"a photo of cuisine",
|
| 401 |
-
"a photo of a dish"
|
| 402 |
-
]
|
| 403 |
|
| 404 |
-
|
| 405 |
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
text_inputs = {k: v.to(self.device) for k, v in text_inputs.items()}
|
| 415 |
-
|
| 416 |
-
with autocast_context(self.device, self.dtype):
|
| 417 |
-
# Get features
|
| 418 |
-
image_features = self.clip_model.get_image_features(**image_inputs)
|
| 419 |
-
text_features = self.clip_model.get_text_features(**text_inputs)
|
| 420 |
-
|
| 421 |
-
# Normalize
|
| 422 |
-
image_features = image_features / image_features.norm(dim=-1, keepdim=True)
|
| 423 |
-
text_features = text_features / text_features.norm(dim=-1, keepdim=True)
|
| 424 |
-
|
| 425 |
-
# Compute similarities
|
| 426 |
-
similarities = (image_features @ text_features.t()).cpu().numpy()[0]
|
| 427 |
-
|
| 428 |
-
# Split similarities
|
| 429 |
-
non_food_sims = similarities[:len(non_food_prompts)]
|
| 430 |
-
food_sims = similarities[len(non_food_prompts):]
|
| 431 |
-
|
| 432 |
-
# Calculate scores
|
| 433 |
-
max_non_food = np.max(non_food_sims)
|
| 434 |
-
max_food = np.max(food_sims)
|
| 435 |
-
|
| 436 |
-
# Decision logic
|
| 437 |
-
is_non_food = max_non_food > max_food and max_non_food > 0.25
|
| 438 |
-
confidence = max_non_food if is_non_food else max_food
|
| 439 |
-
|
| 440 |
-
return is_non_food, float(confidence)
|
| 441 |
-
|
| 442 |
-
except Exception as e:
|
| 443 |
-
logger.warning(f"Non-food detection failed: {e}")
|
| 444 |
-
return False, 0.0
|
| 445 |
-
|
| 446 |
-
def classify_with_primary(self, image: Image.Image) -> Dict[str, Any]:
|
| 447 |
-
"""Klasifikacija sa primary modelom."""
|
| 448 |
-
if "primary" not in self.models:
|
| 449 |
-
return None
|
| 450 |
|
| 451 |
-
|
| 452 |
-
inputs = self.processors["primary"](images=image, return_tensors="pt")
|
| 453 |
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
| 454 |
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
probs = F.softmax(outputs.logits, dim=-1).cpu().numpy()[0]
|
| 458 |
-
|
| 459 |
-
# Get top 5
|
| 460 |
-
top_indices = probs.argsort()[-5:][::-1]
|
| 461 |
-
labels = [self.models["primary"].config.id2label[i] for i in top_indices]
|
| 462 |
-
scores = [float(probs[i]) for i in top_indices]
|
| 463 |
-
|
| 464 |
-
return {
|
| 465 |
-
"primary_label": labels[0],
|
| 466 |
-
"alternatives": labels[1:],
|
| 467 |
-
"confidence": scores[0],
|
| 468 |
-
"top5": list(zip(labels, scores)),
|
| 469 |
-
"model": "primary"
|
| 470 |
-
}
|
| 471 |
-
|
| 472 |
-
except Exception as e:
|
| 473 |
-
logger.warning(f"Primary model classification failed: {e}")
|
| 474 |
-
return None
|
| 475 |
-
|
| 476 |
-
def classify_with_secondary(self, image: Image.Image) -> Dict[str, Any]:
|
| 477 |
-
"""Klasifikacija sa secondary modelom."""
|
| 478 |
-
if "secondary" not in self.models:
|
| 479 |
-
return None
|
| 480 |
-
|
| 481 |
-
try:
|
| 482 |
-
results = self.models["secondary"](image)
|
| 483 |
-
|
| 484 |
-
if not results:
|
| 485 |
-
return None
|
| 486 |
-
|
| 487 |
-
labels = [r["label"] for r in results]
|
| 488 |
-
scores = [r["score"] for r in results]
|
| 489 |
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
try:
|
| 508 |
-
with torch.no_grad():
|
| 509 |
-
# Process image
|
| 510 |
-
image_inputs = self.clip_processor(images=image, return_tensors="pt")
|
| 511 |
-
image_inputs = {k: v.to(self.device) for k, v in image_inputs.items()}
|
| 512 |
-
|
| 513 |
-
with autocast_context(self.device, self.dtype):
|
| 514 |
-
image_features = self.clip_model.get_image_features(**image_inputs)
|
| 515 |
-
image_features = image_features / image_features.norm(dim=-1, keepdim=True)
|
| 516 |
-
|
| 517 |
-
# Compute similarities sa precomputed embeddings
|
| 518 |
-
similarities = (image_features @ self.text_embeddings.t()).cpu().numpy()[0]
|
| 519 |
-
|
| 520 |
-
# Group by main labels
|
| 521 |
-
label_scores = {}
|
| 522 |
-
prompt_idx = 0
|
| 523 |
-
|
| 524 |
-
for label, synonyms in self.food_labels.items():
|
| 525 |
-
scores = []
|
| 526 |
-
# Main label score
|
| 527 |
-
scores.append(similarities[prompt_idx])
|
| 528 |
-
prompt_idx += 1
|
| 529 |
-
|
| 530 |
-
# Synonym scores
|
| 531 |
-
for _ in synonyms[:2]:
|
| 532 |
-
scores.append(similarities[prompt_idx])
|
| 533 |
-
prompt_idx += 1
|
| 534 |
-
|
| 535 |
-
# Take max score for this label
|
| 536 |
-
label_scores[label] = max(scores)
|
| 537 |
-
|
| 538 |
-
# Sort by score
|
| 539 |
-
sorted_labels = sorted(label_scores.items(), key=lambda x: x[1], reverse=True)
|
| 540 |
-
|
| 541 |
-
labels = [item[0] for item in sorted_labels[:5]]
|
| 542 |
-
scores = [float(item[1]) for item in sorted_labels[:5]]
|
| 543 |
-
|
| 544 |
-
return {
|
| 545 |
-
"primary_label": labels[0],
|
| 546 |
-
"alternatives": labels[1:],
|
| 547 |
-
"confidence": scores[0],
|
| 548 |
-
"top5": list(zip(labels, scores)),
|
| 549 |
-
"model": "clip"
|
| 550 |
-
}
|
| 551 |
-
|
| 552 |
-
except Exception as e:
|
| 553 |
-
logger.warning(f"CLIP classification failed: {e}")
|
| 554 |
-
return None
|
| 555 |
-
|
| 556 |
-
def ensemble_classify(self, image: Image.Image) -> Dict[str, Any]:
|
| 557 |
-
"""
|
| 558 |
-
Glavna ensemble klasifikacija koja kombinuje sve modele.
|
| 559 |
-
"""
|
| 560 |
-
logger.info("🔍 Starting ULTRA ensemble classification...")
|
| 561 |
-
|
| 562 |
-
# 1. Non-food detection
|
| 563 |
-
is_non_food, non_food_conf = self.detect_non_food(image)
|
| 564 |
-
if is_non_food and non_food_conf > 0.4:
|
| 565 |
-
logger.info(f"🚫 Non-food object detected (confidence: {non_food_conf:.3f})")
|
| 566 |
-
return {
|
| 567 |
-
"primary_label": "Non-food object",
|
| 568 |
-
"alternatives": [],
|
| 569 |
-
"confidence": non_food_conf,
|
| 570 |
-
"top5": [("Non-food object", non_food_conf)],
|
| 571 |
-
"model": "non_food_detector",
|
| 572 |
-
"is_food": False
|
| 573 |
-
}
|
| 574 |
-
|
| 575 |
-
# 2. Preprocess image variants
|
| 576 |
-
image_variants = advanced_image_preprocessing(image)
|
| 577 |
-
|
| 578 |
-
# 3. Collect predictions from all models
|
| 579 |
-
all_predictions = []
|
| 580 |
-
|
| 581 |
-
for variant_idx, img_variant in enumerate(image_variants):
|
| 582 |
-
# Primary model
|
| 583 |
-
pred = self.classify_with_primary(img_variant)
|
| 584 |
-
if pred and pred["confidence"] > MIN_CONFIDENCE_THRESHOLD:
|
| 585 |
-
pred["variant"] = variant_idx
|
| 586 |
-
all_predictions.append(pred)
|
| 587 |
-
|
| 588 |
-
# Secondary model (samo za prvu varijantu da uštedimo vreme)
|
| 589 |
-
if variant_idx == 0:
|
| 590 |
-
pred = self.classify_with_secondary(img_variant)
|
| 591 |
-
if pred and pred["confidence"] > MIN_CONFIDENCE_THRESHOLD:
|
| 592 |
-
pred["variant"] = variant_idx
|
| 593 |
-
all_predictions.append(pred)
|
| 594 |
-
|
| 595 |
-
# CLIP model
|
| 596 |
-
pred = self.classify_with_clip(img_variant)
|
| 597 |
-
if pred and pred["confidence"] > MIN_CONFIDENCE_THRESHOLD:
|
| 598 |
-
pred["variant"] = variant_idx
|
| 599 |
-
all_predictions.append(pred)
|
| 600 |
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
"primary_label": "Unknown food",
|
| 605 |
-
"alternatives": [],
|
| 606 |
-
"confidence": 0.0,
|
| 607 |
-
"top5": [],
|
| 608 |
-
"model": "ensemble",
|
| 609 |
-
"is_food": True
|
| 610 |
-
}
|
| 611 |
|
| 612 |
-
|
| 613 |
-
final_result = self._ensemble_vote(all_predictions)
|
| 614 |
-
final_result["is_food"] = True
|
| 615 |
|
| 616 |
-
|
| 617 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 618 |
|
| 619 |
-
def
|
| 620 |
-
"""
|
| 621 |
-
Implementira sofisticiran ensemble voting algoritam.
|
| 622 |
"""
|
| 623 |
-
|
| 624 |
-
return {
|
| 625 |
-
"primary_label": "Unknown",
|
| 626 |
-
"alternatives": [],
|
| 627 |
-
"confidence": 0.0,
|
| 628 |
-
"top5": [],
|
| 629 |
-
"model": "ensemble"
|
| 630 |
-
}
|
| 631 |
-
|
| 632 |
-
# Ako imamo samo jednu predikciju
|
| 633 |
-
if len(predictions) == 1:
|
| 634 |
-
result = predictions[0].copy()
|
| 635 |
-
result["model"] = "ensemble"
|
| 636 |
-
return result
|
| 637 |
-
|
| 638 |
-
# Weighted voting based on model confidence and type
|
| 639 |
-
model_weights = {
|
| 640 |
-
"primary": 1.5, # Specijalizovani food model ima najveću težinu
|
| 641 |
-
"secondary": 1.2, # Backup food model
|
| 642 |
-
"clip": 1.0 # CLIP kao fallback
|
| 643 |
-
}
|
| 644 |
-
|
| 645 |
-
# Collect all labels with weighted scores
|
| 646 |
-
label_scores = {}
|
| 647 |
-
|
| 648 |
-
for pred in predictions:
|
| 649 |
-
model_type = pred["model"]
|
| 650 |
-
weight = model_weights.get(model_type, 1.0)
|
| 651 |
-
|
| 652 |
-
# Main label
|
| 653 |
-
main_label = pred["primary_label"]
|
| 654 |
-
confidence = pred["confidence"]
|
| 655 |
-
weighted_score = confidence * weight
|
| 656 |
-
|
| 657 |
-
if main_label in label_scores:
|
| 658 |
-
label_scores[main_label] += weighted_score
|
| 659 |
-
else:
|
| 660 |
-
label_scores[main_label] = weighted_score
|
| 661 |
-
|
| 662 |
-
# Alternative labels (sa manjom težinom)
|
| 663 |
-
for alt_label in pred["alternatives"][:2]: # Top 2 alternative
|
| 664 |
-
alt_weight = weight * 0.3
|
| 665 |
-
if alt_label in label_scores:
|
| 666 |
-
label_scores[alt_label] += alt_weight
|
| 667 |
-
else:
|
| 668 |
-
label_scores[alt_label] = alt_weight
|
| 669 |
-
|
| 670 |
-
# Sort by weighted score
|
| 671 |
-
sorted_labels = sorted(label_scores.items(), key=lambda x: x[1], reverse=True)
|
| 672 |
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
|
|
|
|
|
|
| 676 |
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 680 |
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
confidence_boost = 1.1 # Boost confidence if models agree
|
| 684 |
-
else:
|
| 685 |
-
confidence_boost = 1.0
|
| 686 |
-
|
| 687 |
-
final_confidence = min(top_scores[0] * confidence_boost, 1.0)
|
| 688 |
|
| 689 |
-
return
|
| 690 |
-
"primary_label": top_labels[0],
|
| 691 |
-
"alternatives": top_labels[1:4],
|
| 692 |
-
"confidence": final_confidence,
|
| 693 |
-
"top5": list(zip(top_labels, top_scores)),
|
| 694 |
-
"model": "ensemble",
|
| 695 |
-
"num_models": len(predictions)
|
| 696 |
-
}
|
| 697 |
|
| 698 |
-
# --- Nutrition Functions (unchanged from original) ---
|
| 699 |
-
def clean_food_name(food_name: str) -> str:
|
| 700 |
-
"""Čisti naziv hrane za nutrition pretragu."""
|
| 701 |
-
name = food_name.lower().strip()
|
| 702 |
-
remove_words = [
|
| 703 |
-
'a', 'an', 'the', 'with', 'and', 'or', 'of', 'in', 'on',
|
| 704 |
-
'some', 'various', 'different', 'multiple', 'several'
|
| 705 |
-
]
|
| 706 |
-
words = name.split()
|
| 707 |
-
words = [w for w in words if w not in remove_words]
|
| 708 |
-
return ' '.join(words) if words else food_name
|
| 709 |
|
| 710 |
-
def search_nutrition_data(food_name: str
|
| 711 |
"""Pretražuje nutritivne podatke preko Open Food Facts API-ja."""
|
| 712 |
-
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
}
|
| 729 |
-
|
| 730 |
-
response = requests.get(search_url, params=params, timeout=5)
|
| 731 |
|
| 732 |
-
if
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
for
|
| 737 |
-
|
| 738 |
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
"
|
| 744 |
-
"
|
| 745 |
-
"
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
except Exception as e:
|
| 760 |
-
logger.warning(f"⚠️ Greška pri pretraživanju '{term}': {e}")
|
| 761 |
-
continue
|
| 762 |
|
| 763 |
-
logger.warning(f"⚠️ Nisu pronađeni podaci, koristim procjenu za: '{food_name}'")
|
| 764 |
return get_estimated_nutrition(food_name)
|
| 765 |
|
|
|
|
| 766 |
def get_estimated_nutrition(food_name: str) -> Dict[str, Any]:
|
| 767 |
-
"""Vraća procijenjene nutritivne vrijednosti
|
| 768 |
food_lower = food_name.lower()
|
| 769 |
|
| 770 |
categories = {
|
|
@@ -776,19 +251,17 @@ def get_estimated_nutrition(food_name: str) -> Dict[str, Any]:
|
|
| 776 |
'dairy': {'calories': 60, 'protein': 3.5, 'carbs': 5, 'fat': 3, 'fiber': 0, 'sugar': 5, 'sodium': 50},
|
| 777 |
'dessert': {'calories': 350, 'protein': 4, 'carbs': 50, 'fat': 15, 'fiber': 1, 'sugar': 40, 'sodium': 200},
|
| 778 |
'fast_food': {'calories': 250, 'protein': 12, 'carbs': 30, 'fat': 10, 'fiber': 2, 'sugar': 5, 'sodium': 600},
|
| 779 |
-
'bread': {'calories': 265, 'protein': 9, 'carbs': 49, 'fat': 3.2, 'fiber': 2.7, 'sugar': 5, 'sodium': 500},
|
| 780 |
}
|
| 781 |
|
| 782 |
category_keywords = {
|
| 783 |
-
'fruit': ['apple', 'banana', 'orange', 'berry', 'fruit'
|
| 784 |
-
'vegetable': ['salad', '
|
| 785 |
-
'meat': ['chicken', 'beef', 'pork', 'steak', 'meat'
|
| 786 |
-
'fish': ['fish', 'salmon', 'tuna', 'seafood'
|
| 787 |
-
'grain': ['rice', 'pasta', 'noodle', 'bread'
|
| 788 |
-
'dairy': ['
|
| 789 |
-
'dessert': ['cake', 'cookie', 'chocolate', 'ice cream'
|
| 790 |
-
'fast_food': ['burger', 'pizza', 'fries'
|
| 791 |
-
'bread': ['bread', 'roll', 'bun', 'toast']
|
| 792 |
}
|
| 793 |
|
| 794 |
detected_category = 'grain'
|
|
@@ -806,60 +279,50 @@ def get_estimated_nutrition(food_name: str) -> Dict[str, Any]:
|
|
| 806 |
"source": "AI Estimation",
|
| 807 |
"serving_size": 100,
|
| 808 |
"serving_unit": "g",
|
| 809 |
-
"note": "
|
| 810 |
}
|
| 811 |
|
|
|
|
| 812 |
def is_image_file(file: UploadFile):
|
| 813 |
-
"""Provjerava da li je fajl
|
| 814 |
return file.content_type in ["image/jpeg", "image/png", "image/jpg", "image/webp"]
|
| 815 |
|
| 816 |
-
|
| 817 |
-
|
|
|
|
| 818 |
device = select_device()
|
| 819 |
-
|
| 820 |
-
logger.info(f"Using device: {device} | dtype: {dtype}")
|
| 821 |
|
| 822 |
-
|
| 823 |
-
ultra_classifier = UltraFoodClassifier(device, dtype)
|
| 824 |
|
| 825 |
# --- FastAPI Application ---
|
| 826 |
app = FastAPI(
|
| 827 |
-
title="
|
| 828 |
description="""
|
| 829 |
-
|
| 830 |
-
|
| 831 |
-
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
-
|
| 835 |
-
-
|
| 836 |
-
-
|
| 837 |
-
-
|
| 838 |
-
|
| 839 |
-
|
| 840 |
-
|
| 841 |
-
|
| 842 |
-
|
| 843 |
-
### 🎯
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
5. **Confidence Filtering** - Odbacuje nesigurne rezultate
|
| 849 |
-
6. **Nutrition Lookup** - Automatski pronalazi nutritivne podatke
|
| 850 |
-
|
| 851 |
-
### 🏆 ULTRA Prednosti:
|
| 852 |
-
- 🎯 **99% Accuracy** - Nikad više pogrešnih rezultata
|
| 853 |
-
- 🚫 **Zero False Positives** - Non-food objekti se automatski odbacuju
|
| 854 |
-
- ⚡ **Production Ready** - Optimizovano za real-world usage
|
| 855 |
-
- 🔒 **Self-hosted** - Potpuna kontrola i privatnost
|
| 856 |
-
- 💰 **100% Free** - Bez API troškova
|
| 857 |
-
- 🌍 **Offline Capable** - Radi bez interneta (osim nutrition lookup)
|
| 858 |
""",
|
| 859 |
-
version="
|
| 860 |
)
|
| 861 |
|
| 862 |
-
# CORS
|
| 863 |
app.add_middleware(
|
| 864 |
CORSMiddleware,
|
| 865 |
allow_origins=["*"],
|
|
@@ -868,404 +331,281 @@ app.add_middleware(
|
|
| 868 |
allow_headers=["*"],
|
| 869 |
)
|
| 870 |
|
|
|
|
| 871 |
@app.post("/analyze",
|
| 872 |
-
summary="🎯
|
| 873 |
-
description="Upload sliku za
|
| 874 |
-
response_description="ULTRA-precizni rezultati food recognition i nutritivnih podataka"
|
| 875 |
)
|
| 876 |
async def analyze(file: UploadFile = File(...)):
|
| 877 |
"""
|
| 878 |
-
|
| 879 |
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
### 🎯 ULTRA Features:
|
| 883 |
-
- Ensemble od 3+ specijalizovana modela
|
| 884 |
-
- Non-food detection
|
| 885 |
-
- Multi-variant image processing
|
| 886 |
-
- Smart confidence filtering
|
| 887 |
-
- Intelligent voting algoritam
|
| 888 |
-
- Automatski nutrition lookup
|
| 889 |
"""
|
| 890 |
if not file:
|
| 891 |
-
raise HTTPException(status_code=400, detail="
|
| 892 |
|
| 893 |
if not is_image_file(file):
|
| 894 |
-
raise HTTPException(
|
| 895 |
-
status_code=400,
|
| 896 |
-
detail="Nepodržan format slike. Koristi JPEG, PNG ili WebP."
|
| 897 |
-
)
|
| 898 |
|
| 899 |
try:
|
|
|
|
| 900 |
contents = await file.read()
|
| 901 |
image = Image.open(BytesIO(contents))
|
| 902 |
|
| 903 |
-
# Konvertuj u RGB ako je potrebno
|
| 904 |
if image.mode != "RGB":
|
| 905 |
image = image.convert("RGB")
|
| 906 |
|
| 907 |
-
# Sačuvaj dimenzije slike
|
| 908 |
image_width, image_height = image.size
|
|
|
|
| 909 |
except Exception as e:
|
| 910 |
-
raise HTTPException(status_code=500, detail=f"
|
| 911 |
|
| 912 |
try:
|
| 913 |
-
#
|
| 914 |
-
|
| 915 |
-
classification = ultra_classifier.ensemble_classify(image)
|
| 916 |
|
| 917 |
-
|
| 918 |
-
if not classification.get("is_food", True):
|
| 919 |
return JSONResponse(content={
|
| 920 |
"success": False,
|
| 921 |
"error": "Non-food object detected",
|
| 922 |
-
"message": "
|
| 923 |
-
"
|
| 924 |
-
"confidence": classification["confidence"],
|
| 925 |
-
"model_info": {
|
| 926 |
-
"type": "ULTRA Non-food Detector",
|
| 927 |
-
"version": "10.0.0"
|
| 928 |
-
}
|
| 929 |
})
|
| 930 |
|
| 931 |
-
#
|
| 932 |
-
|
|
|
|
|
|
|
|
|
|
| 933 |
raise HTTPException(
|
| 934 |
status_code=422,
|
| 935 |
-
detail=f"
|
| 936 |
)
|
| 937 |
-
|
| 938 |
except HTTPException:
|
| 939 |
raise
|
| 940 |
except Exception as e:
|
| 941 |
-
logger.error(f"
|
| 942 |
-
raise HTTPException(status_code=500, detail=f"
|
| 943 |
|
| 944 |
# Get nutrition data
|
| 945 |
-
logger.info(f"🍎
|
| 946 |
-
nutrition_data = search_nutrition_data(
|
| 947 |
-
classification["primary_label"],
|
| 948 |
-
alternatives=classification["alternatives"]
|
| 949 |
-
)
|
| 950 |
|
| 951 |
-
# Prepare
|
| 952 |
-
|
| 953 |
"success": True,
|
| 954 |
-
"label":
|
| 955 |
-
"confidence":
|
| 956 |
-
"
|
| 957 |
|
| 958 |
-
# Nutrition
|
| 959 |
"nutrition": nutrition_data["nutrition"],
|
| 960 |
"source": nutrition_data["source"],
|
| 961 |
|
| 962 |
-
#
|
| 963 |
-
"alternatives": classification["alternatives"],
|
| 964 |
-
|
| 965 |
-
# ULTRA AI analysis
|
| 966 |
-
"ai_analysis": {
|
| 967 |
-
"detailed_description": f"ULTRA ensemble analysis: {classification['primary_label']} detected with {classification['confidence']:.1%} confidence using {classification.get('num_models', 1)} specialized models.",
|
| 968 |
-
"food_items": f"1) {classification['primary_label']}",
|
| 969 |
-
"confidence_level": "High" if classification["confidence"] > HIGH_CONFIDENCE_THRESHOLD else "Medium",
|
| 970 |
-
"model_agreement": f"{classification.get('num_models', 1)} models participated in ensemble voting"
|
| 971 |
-
},
|
| 972 |
-
|
| 973 |
"image_info": {
|
| 974 |
"width": image_width,
|
| 975 |
"height": image_height,
|
| 976 |
"format": image.format
|
| 977 |
},
|
| 978 |
|
|
|
|
| 979 |
"model_info": {
|
| 980 |
-
"type": "
|
| 981 |
-
"
|
| 982 |
-
"
|
| 983 |
-
"
|
| 984 |
-
"
|
| 985 |
-
"
|
| 986 |
-
"features": [
|
| 987 |
-
"Multi-model Ensemble",
|
| 988 |
-
"Non-food Detection",
|
| 989 |
-
"Advanced Preprocessing",
|
| 990 |
-
"Confidence Filtering",
|
| 991 |
-
"Smart Voting Algorithm"
|
| 992 |
-
]
|
| 993 |
}
|
| 994 |
}
|
| 995 |
|
| 996 |
-
return JSONResponse(content=
|
|
|
|
| 997 |
|
| 998 |
-
@app.
|
| 999 |
-
summary="
|
| 1000 |
-
description="
|
| 1001 |
)
|
| 1002 |
-
async def
|
| 1003 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1004 |
try:
|
| 1005 |
-
|
|
|
|
| 1006 |
|
| 1007 |
-
|
|
|
|
| 1008 |
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
|
| 1013 |
-
|
| 1014 |
|
| 1015 |
return JSONResponse(content={
|
| 1016 |
"success": True,
|
| 1017 |
-
"
|
| 1018 |
-
"
|
| 1019 |
-
"
|
| 1020 |
-
"
|
| 1021 |
-
"
|
| 1022 |
-
|
|
|
|
|
|
|
|
|
|
| 1023 |
})
|
| 1024 |
|
| 1025 |
-
except HTTPException:
|
| 1026 |
-
raise
|
| 1027 |
except Exception as e:
|
| 1028 |
-
logger.error(f"
|
| 1029 |
-
raise HTTPException(
|
| 1030 |
-
|
| 1031 |
-
detail=f"Greška pri pretraživanju: {e}"
|
| 1032 |
-
)
|
| 1033 |
|
| 1034 |
-
@app.get("/",
|
| 1035 |
-
summary="
|
| 1036 |
-
description="Informacije o
|
| 1037 |
)
|
| 1038 |
def root():
|
| 1039 |
-
"""Root endpoint sa
|
| 1040 |
return {
|
| 1041 |
-
"message": "
|
| 1042 |
-
"status": "🟢 Online &
|
| 1043 |
-
"tagline": "
|
| 1044 |
"model": {
|
| 1045 |
-
"
|
| 1046 |
-
"
|
| 1047 |
-
"
|
| 1048 |
-
"models": list(FOOD_MODELS.values()) + [CLIP_MODEL_NAME],
|
| 1049 |
-
"ensemble_method": "Weighted Voting with Confidence Filtering",
|
| 1050 |
"device": device.upper(),
|
| 1051 |
-
"
|
| 1052 |
-
},
|
| 1053 |
-
"ultra_features": {
|
| 1054 |
-
"ensemble_models": "✅ 3+ Specialized Food Models",
|
| 1055 |
-
"non_food_detection": "✅ Automatic Non-food Filtering",
|
| 1056 |
-
"advanced_preprocessing": "✅ 5-variant Image Processing",
|
| 1057 |
-
"confidence_filtering": "✅ Smart Threshold Management",
|
| 1058 |
-
"intelligent_voting": "✅ Weighted Ensemble Algorithm",
|
| 1059 |
-
"optimized_labels": "✅ Food-101 with Synonyms",
|
| 1060 |
-
"nutrition_data": "✅ Real Nutritional Information",
|
| 1061 |
-
"offline_capable": "✅ Works Without Internet (vision only)"
|
| 1062 |
},
|
| 1063 |
-
"
|
| 1064 |
-
"
|
| 1065 |
-
"
|
| 1066 |
-
"
|
| 1067 |
-
"
|
|
|
|
|
|
|
| 1068 |
},
|
| 1069 |
"endpoints": {
|
| 1070 |
-
"POST /analyze": "🎯
|
| 1071 |
-
"
|
| 1072 |
-
"GET /health": "💚
|
| 1073 |
-
"GET /
|
| 1074 |
},
|
| 1075 |
-
"
|
| 1076 |
-
"
|
| 1077 |
-
"
|
| 1078 |
-
"
|
| 1079 |
-
"
|
| 1080 |
-
|
| 1081 |
-
|
| 1082 |
-
|
| 1083 |
-
|
|
|
|
|
|
|
| 1084 |
}
|
| 1085 |
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
|
|
|
|
| 1089 |
)
|
| 1090 |
def health_check():
|
| 1091 |
-
"""
|
| 1092 |
-
# Check model availability
|
| 1093 |
-
models_loaded = {
|
| 1094 |
-
"primary": "primary" in ultra_classifier.models,
|
| 1095 |
-
"secondary": "secondary" in ultra_classifier.models,
|
| 1096 |
-
"clip": ultra_classifier.clip_model is not None
|
| 1097 |
-
}
|
| 1098 |
-
|
| 1099 |
-
models_healthy = sum(models_loaded.values())
|
| 1100 |
-
overall_health = "healthy" if models_healthy >= 2 else "degraded" if models_healthy >= 1 else "unhealthy"
|
| 1101 |
-
|
| 1102 |
-
# Test nutrition API
|
| 1103 |
-
nutrition_api_status = "unknown"
|
| 1104 |
try:
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
nutrition_api_status = "
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
"
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
"
|
| 1122 |
-
"name": FOOD_MODELS["secondary"],
|
| 1123 |
-
"loaded": models_loaded["secondary"],
|
| 1124 |
-
"status": "healthy" if models_loaded["secondary"] else "failed"
|
| 1125 |
-
},
|
| 1126 |
-
"clip_model": {
|
| 1127 |
"name": CLIP_MODEL_NAME,
|
| 1128 |
-
"loaded":
|
| 1129 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1130 |
}
|
| 1131 |
-
},
|
| 1132 |
-
"ensemble_status": f"{models_healthy}/3 models loaded",
|
| 1133 |
-
"nutrition_api": nutrition_api_status,
|
| 1134 |
-
"accuracy_rating": "99%+" if models_healthy >= 2 else "Degraded",
|
| 1135 |
-
"capabilities": {
|
| 1136 |
-
"food_recognition": models_healthy >= 1,
|
| 1137 |
-
"non_food_detection": models_loaded["clip"],
|
| 1138 |
-
"ensemble_voting": models_healthy >= 2,
|
| 1139 |
-
"nutrition_lookup": nutrition_api_status in ["healthy", "degraded"]
|
| 1140 |
}
|
| 1141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1142 |
|
| 1143 |
-
@app.get("/
|
| 1144 |
-
summary="📋
|
| 1145 |
-
description="
|
| 1146 |
)
|
| 1147 |
-
def
|
| 1148 |
-
"""Vraća
|
| 1149 |
return {
|
| 1150 |
-
"
|
| 1151 |
-
"
|
| 1152 |
-
"
|
| 1153 |
-
"specialization": "Food-only Recognition with Ensemble Intelligence",
|
| 1154 |
-
|
| 1155 |
-
"core_models": {
|
| 1156 |
-
"primary": {
|
| 1157 |
-
"name": FOOD_MODELS["primary"],
|
| 1158 |
-
"type": "Specialized Food Classifier",
|
| 1159 |
-
"weight": 1.5,
|
| 1160 |
-
"purpose": "Primary food recognition"
|
| 1161 |
-
},
|
| 1162 |
-
"secondary": {
|
| 1163 |
-
"name": FOOD_MODELS["secondary"],
|
| 1164 |
-
"type": "Food Classification Pipeline",
|
| 1165 |
-
"weight": 1.2,
|
| 1166 |
-
"purpose": "Backup food recognition"
|
| 1167 |
-
},
|
| 1168 |
-
"clip": {
|
| 1169 |
-
"name": CLIP_MODEL_NAME,
|
| 1170 |
-
"type": "Vision-Language Model",
|
| 1171 |
-
"weight": 1.0,
|
| 1172 |
-
"purpose": "Non-food detection & fallback"
|
| 1173 |
-
}
|
| 1174 |
-
},
|
| 1175 |
-
|
| 1176 |
-
"ultra_features": {
|
| 1177 |
-
"ensemble_classification": {
|
| 1178 |
-
"description": "Combines 3+ specialized models using weighted voting",
|
| 1179 |
-
"method": "Confidence-weighted ensemble with agreement thresholds",
|
| 1180 |
-
"accuracy_boost": "15-25% over single model"
|
| 1181 |
-
},
|
| 1182 |
-
"non_food_detection": {
|
| 1183 |
-
"description": "Automatically detects and rejects non-food objects",
|
| 1184 |
-
"method": "CLIP-based semantic understanding",
|
| 1185 |
-
"false_positive_reduction": "95%+"
|
| 1186 |
-
},
|
| 1187 |
-
"advanced_preprocessing": {
|
| 1188 |
-
"description": "Generates 5 optimized image variants for analysis",
|
| 1189 |
-
"variants": ["Original", "Enhanced contrast", "Brightened", "Sharpened", "Center cropped"],
|
| 1190 |
-
"accuracy_improvement": "10-15%"
|
| 1191 |
-
},
|
| 1192 |
-
"confidence_filtering": {
|
| 1193 |
-
"description": "Rejects low-confidence predictions to ensure quality",
|
| 1194 |
-
"min_threshold": MIN_CONFIDENCE_THRESHOLD,
|
| 1195 |
-
"high_threshold": HIGH_CONFIDENCE_THRESHOLD,
|
| 1196 |
-
"reliability": "99%+"
|
| 1197 |
-
},
|
| 1198 |
-
"optimized_labels": {
|
| 1199 |
-
"description": "Food-101 labels enhanced with synonyms and variants",
|
| 1200 |
-
"total_labels": len(get_optimized_food101_labels()),
|
| 1201 |
-
"synonym_mapping": "2-3 synonyms per label",
|
| 1202 |
-
"coverage": "Comprehensive food categories"
|
| 1203 |
-
}
|
| 1204 |
-
},
|
| 1205 |
-
|
| 1206 |
-
"performance_metrics": {
|
| 1207 |
-
"accuracy": "99%+ on clear food images",
|
| 1208 |
-
"precision": "98%+ (very few false positives)",
|
| 1209 |
-
"recall": "97%+ (catches most food items)",
|
| 1210 |
-
"f1_score": "98%+",
|
| 1211 |
-
"non_food_rejection": "95%+ accuracy",
|
| 1212 |
-
"inference_time": "< 2 seconds per image"
|
| 1213 |
-
},
|
| 1214 |
-
|
| 1215 |
-
"use_cases": [
|
| 1216 |
-
"🍽️ Professional nutrition tracking applications",
|
| 1217 |
-
"📱 Consumer calorie counting apps",
|
| 1218 |
-
"🏥 Medical dietary monitoring systems",
|
| 1219 |
-
"🍕 Restaurant menu digitalization",
|
| 1220 |
-
"🛒 Grocery shopping assistants",
|
| 1221 |
-
"👨🍳 Recipe analysis and ingredient detection",
|
| 1222 |
-
"📊 Food industry quality control",
|
| 1223 |
-
"🎓 Educational food recognition tools",
|
| 1224 |
-
"🔬 Research applications in food science",
|
| 1225 |
-
"🌍 Agricultural product classification"
|
| 1226 |
-
],
|
| 1227 |
-
|
| 1228 |
-
"technical_advantages": [
|
| 1229 |
-
"🎯 Highest accuracy in food recognition",
|
| 1230 |
-
"🚫 Eliminates false positives with non-food detection",
|
| 1231 |
-
"⚡ Production-optimized for real-world usage",
|
| 1232 |
-
"🔒 Complete privacy with self-hosting",
|
| 1233 |
-
"💰 Zero ongoing costs (no API fees)",
|
| 1234 |
-
"🌍 Works offline for vision tasks",
|
| 1235 |
-
"🔄 Continuous improvement through ensemble learning",
|
| 1236 |
-
"📊 Real nutritional data integration",
|
| 1237 |
-
"🛡️ Robust error handling and fallbacks",
|
| 1238 |
-
"⚙️ Highly configurable and extensible"
|
| 1239 |
-
]
|
| 1240 |
}
|
| 1241 |
|
| 1242 |
-
|
|
|
|
| 1243 |
if __name__ == "__main__":
|
| 1244 |
-
print("=" *
|
| 1245 |
-
print("
|
| 1246 |
-
print("=" *
|
| 1247 |
-
print("
|
| 1248 |
-
print(" ✅
|
| 1249 |
-
print(" ✅
|
| 1250 |
-
print(" ✅
|
| 1251 |
-
print(" ✅
|
| 1252 |
-
print(" ✅
|
| 1253 |
-
print("
|
| 1254 |
-
print("
|
| 1255 |
-
print(" ✅ Realni nutritivni podaci iz Open Food Facts")
|
| 1256 |
-
print("=" * 100)
|
| 1257 |
-
print(f"🤖 Primary Model: {FOOD_MODELS['primary']}")
|
| 1258 |
-
print(f"🤖 Secondary Model: {FOOD_MODELS['secondary']}")
|
| 1259 |
-
print(f"🤖 CLIP Model: {CLIP_MODEL_NAME}")
|
| 1260 |
print(f"💻 Device: {device.upper()}")
|
| 1261 |
-
print(f"
|
| 1262 |
-
print(
|
| 1263 |
-
print("=" * 100)
|
| 1264 |
|
| 1265 |
run_port = int(os.environ.get("PORT", "8000"))
|
| 1266 |
-
print(f"🌍
|
| 1267 |
-
print(f"📚
|
| 1268 |
-
print("
|
| 1269 |
-
print("=" * 100)
|
| 1270 |
|
| 1271 |
uvicorn.run(app, host="0.0.0.0", port=run_port)
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
"""
|
| 3 |
+
🎯 Zero-Shot Food Recognition API - CLIP Edition
|
| 4 |
+
================================================
|
| 5 |
|
| 6 |
+
Jednostavan i moćan food recognition sistem baziran na CLIP modelu.
|
| 7 |
|
| 8 |
+
Ključne mogućnosti:
|
| 9 |
+
- 🌍 Zero-shot prepoznavanje - prepoznaje bilo šta bez dodatnog treninga
|
| 10 |
+
- 🎯 Veliki spektar objekata - ne samo hrana, već sve
|
| 11 |
+
- 🚀 Jednostavan i čist kod
|
| 12 |
+
- 📊 Visoka preciznost sa CLIP-om
|
| 13 |
+
- 🏷️ Customizabilne labele
|
| 14 |
+
- ⚡ Brza inferenca
|
|
|
|
| 15 |
|
| 16 |
Autor: AI Assistant
|
| 17 |
+
Verzija: 11.0.0 - ZERO-SHOT CLIP EDITION
|
| 18 |
"""
|
| 19 |
|
| 20 |
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
import logging
|
| 22 |
+
from io import BytesIO
|
| 23 |
+
from typing import Optional, Dict, Any, List
|
| 24 |
|
| 25 |
import uvicorn
|
| 26 |
+
from fastapi import FastAPI, File, UploadFile, HTTPException
|
| 27 |
from fastapi.responses import JSONResponse
|
| 28 |
from fastapi.middleware.cors import CORSMiddleware
|
| 29 |
|
| 30 |
# Image processing
|
| 31 |
+
from PIL import Image
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
import torch
|
| 33 |
+
from transformers import CLIPProcessor, CLIPModel
|
| 34 |
+
|
| 35 |
+
# Nutrition lookup
|
| 36 |
+
import requests
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
# Setup logging
|
| 39 |
logging.basicConfig(level=logging.INFO)
|
| 40 |
logger = logging.getLogger(__name__)
|
| 41 |
|
| 42 |
+
# --- CONFIGURATION ---
|
| 43 |
+
# CLIP model - najbolji za zero-shot classification
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
CLIP_MODEL_NAME = "openai/clip-vit-large-patch14"
|
| 45 |
+
MIN_CONFIDENCE = 0.15
|
| 46 |
+
|
| 47 |
+
# Food-101 categories za food recognition
|
| 48 |
+
FOOD_CATEGORIES = [
|
| 49 |
+
"apple pie", "baby back ribs", "baklava", "beef carpaccio", "beef tartare",
|
| 50 |
+
"beet salad", "beignets", "bibimbap", "bread pudding", "breakfast burrito",
|
| 51 |
+
"bruschetta", "caesar salad", "cannoli", "caprese salad", "carrot cake",
|
| 52 |
+
"ceviche", "cheesecake", "cheese plate", "chicken curry", "chicken quesadilla",
|
| 53 |
+
"chicken wings", "chocolate cake", "chocolate mousse", "churros", "clam chowder",
|
| 54 |
+
"club sandwich", "crab cakes", "creme brulee", "croque madame", "cup cakes",
|
| 55 |
+
"deviled eggs", "donuts", "dumplings", "edamame", "eggs benedict",
|
| 56 |
+
"escargots", "falafel", "filet mignon", "fish and chips", "foie gras",
|
| 57 |
+
"french fries", "french onion soup", "french toast", "fried calamari", "fried rice",
|
| 58 |
+
"frozen yogurt", "garlic bread", "gnocchi", "greek salad", "grilled cheese sandwich",
|
| 59 |
+
"grilled salmon", "guacamole", "gyoza", "hamburger", "hot and sour soup",
|
| 60 |
+
"hot dog", "huevos rancheros", "hummus", "ice cream", "lasagna",
|
| 61 |
+
"lobster bisque", "lobster roll sandwich", "macaroni and cheese", "macarons", "miso soup",
|
| 62 |
+
"mussels", "nachos", "omelette", "onion rings", "oysters",
|
| 63 |
+
"pad thai", "paella", "pancakes", "panna cotta", "peking duck",
|
| 64 |
+
"pho", "pizza", "pork chop", "poutine", "prime rib",
|
| 65 |
+
"pulled pork sandwich", "ramen", "ravioli", "red velvet cake", "risotto",
|
| 66 |
+
"samosa", "sashimi", "scallops", "seaweed salad", "shrimp and grits",
|
| 67 |
+
"spaghetti bolognese", "spaghetti carbonara", "spring rolls", "steak", "strawberry shortcake",
|
| 68 |
+
"sushi", "tacos", "takoyaki", "tiramisu", "tuna tartare", "waffles"
|
| 69 |
]
|
| 70 |
|
| 71 |
+
|
| 72 |
def select_device() -> str:
|
| 73 |
+
"""Odabire najbolji dostupni uređaj."""
|
| 74 |
if torch.cuda.is_available():
|
| 75 |
return "cuda"
|
| 76 |
+
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
| 77 |
+
return "mps"
|
|
|
|
|
|
|
|
|
|
| 78 |
return "cpu"
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
+
class ZeroShotFoodClassifier:
|
|
|
|
|
|
|
|
|
|
| 82 |
"""
|
| 83 |
+
Zero-shot food classifier baziran na CLIP modelu.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
+
CLIP (Contrastive Language-Image Pre-training) je model koji može
|
| 86 |
+
prepoznati bilo koji objekat bez dodatnog treninga - jednostavno mu
|
| 87 |
+
kažeš šta da traži i on to prepoznaje.
|
| 88 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
+
def __init__(self, device: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
self.device = device
|
| 92 |
+
logger.info(f"🚀 Loading CLIP model: {CLIP_MODEL_NAME}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
+
# Load CLIP model i processor
|
| 95 |
+
self.processor = CLIPProcessor.from_pretrained(CLIP_MODEL_NAME)
|
| 96 |
+
self.model = CLIPModel.from_pretrained(CLIP_MODEL_NAME).to(device)
|
| 97 |
+
self.model.eval()
|
| 98 |
|
| 99 |
+
logger.info("✅ CLIP model loaded successfully!")
|
| 100 |
+
|
| 101 |
+
def classify_food(self, image: Image.Image, custom_categories: List[str] = None) -> Dict[str, Any]:
|
| 102 |
+
"""
|
| 103 |
+
Klasifikuje hranu na slici koristeći zero-shot CLIP pristup.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
|
| 105 |
+
Args:
|
| 106 |
+
image: PIL slika za analizu
|
| 107 |
+
custom_categories: Opcione custom kategorije (ako nisu date, koristi Food-101)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
+
Returns:
|
| 110 |
+
Dictionary sa rezultatima klasifikacije
|
|
|
|
|
|
|
| 111 |
"""
|
| 112 |
+
# Koristi custom kategorije ili default food categories
|
| 113 |
+
categories = custom_categories if custom_categories else FOOD_CATEGORIES
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
+
# Generiši text prompts za svaku kategoriju
|
| 116 |
+
text_prompts = [f"a photo of {category}" for category in categories]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
+
logger.info(f"🔍 Analyzing image with {len(categories)} categories...")
|
| 119 |
|
| 120 |
+
# Process inputs
|
| 121 |
+
with torch.no_grad():
|
| 122 |
+
inputs = self.processor(
|
| 123 |
+
text=text_prompts,
|
| 124 |
+
images=image,
|
| 125 |
+
return_tensors="pt",
|
| 126 |
+
padding=True
|
| 127 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
+
# Move to device
|
|
|
|
| 130 |
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
| 131 |
|
| 132 |
+
# Get predictions
|
| 133 |
+
outputs = self.model(**inputs)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
+
# Calculate similarity scores
|
| 136 |
+
logits_per_image = outputs.logits_per_image
|
| 137 |
+
probs = logits_per_image.softmax(dim=1).cpu().numpy()[0]
|
| 138 |
+
|
| 139 |
+
# Sort by probability
|
| 140 |
+
sorted_indices = probs.argsort()[::-1]
|
| 141 |
+
|
| 142 |
+
# Get top 5 results
|
| 143 |
+
top5_results = []
|
| 144 |
+
for idx in sorted_indices[:5]:
|
| 145 |
+
category = categories[idx]
|
| 146 |
+
confidence = float(probs[idx])
|
| 147 |
+
top5_results.append({
|
| 148 |
+
"label": category,
|
| 149 |
+
"confidence": confidence
|
| 150 |
+
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
+
# Best result
|
| 153 |
+
best_label = categories[sorted_indices[0]]
|
| 154 |
+
best_confidence = float(probs[sorted_indices[0]])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
+
logger.info(f"✅ Best match: {best_label} ({best_confidence:.2%})")
|
|
|
|
|
|
|
| 157 |
|
| 158 |
+
return {
|
| 159 |
+
"primary_label": best_label,
|
| 160 |
+
"confidence": best_confidence,
|
| 161 |
+
"top5": top5_results,
|
| 162 |
+
"alternatives": [r["label"] for r in top5_results[1:4]]
|
| 163 |
+
}
|
| 164 |
|
| 165 |
+
def detect_if_food(self, image: Image.Image) -> tuple[bool, float]:
|
|
|
|
|
|
|
| 166 |
"""
|
| 167 |
+
Detektuje da li slika sadrži hranu.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
|
| 169 |
+
Returns:
|
| 170 |
+
(is_food, confidence) tuple
|
| 171 |
+
"""
|
| 172 |
+
categories = ["food", "non-food object"]
|
| 173 |
+
text_prompts = [f"a photo of {cat}" for cat in categories]
|
| 174 |
|
| 175 |
+
with torch.no_grad():
|
| 176 |
+
inputs = self.processor(
|
| 177 |
+
text=text_prompts,
|
| 178 |
+
images=image,
|
| 179 |
+
return_tensors="pt",
|
| 180 |
+
padding=True
|
| 181 |
+
)
|
| 182 |
+
inputs = {k: v.to(self.device) for k, v in inputs.items()}
|
| 183 |
+
outputs = self.model(**inputs)
|
| 184 |
+
probs = outputs.logits_per_image.softmax(dim=1).cpu().numpy()[0]
|
| 185 |
|
| 186 |
+
is_food = probs[0] > probs[1]
|
| 187 |
+
confidence = float(probs[0] if is_food else probs[1])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
+
return is_food, confidence
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
|
| 192 |
+
def search_nutrition_data(food_name: str) -> Optional[Dict[str, Any]]:
|
| 193 |
"""Pretražuje nutritivne podatke preko Open Food Facts API-ja."""
|
| 194 |
+
try:
|
| 195 |
+
logger.info(f"🔍 Searching nutrition data for: '{food_name}'")
|
| 196 |
+
|
| 197 |
+
search_url = "https://world.openfoodfacts.org/cgi/search.pl"
|
| 198 |
+
params = {
|
| 199 |
+
"search_terms": food_name,
|
| 200 |
+
"search_simple": 1,
|
| 201 |
+
"action": "process",
|
| 202 |
+
"json": 1,
|
| 203 |
+
"page_size": 5
|
| 204 |
+
}
|
| 205 |
+
|
| 206 |
+
response = requests.get(search_url, params=params, timeout=5)
|
| 207 |
+
|
| 208 |
+
if response.status_code == 200:
|
| 209 |
+
data = response.json()
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
+
if data.get('products') and len(data['products']) > 0:
|
| 212 |
+
for product in data['products']:
|
| 213 |
+
nutriments = product.get('nutriments', {})
|
| 214 |
+
|
| 215 |
+
if all(key in nutriments for key in ['energy-kcal_100g', 'proteins_100g', 'carbohydrates_100g', 'fat_100g']):
|
| 216 |
+
logger.info(f"✅ Found nutrition data")
|
| 217 |
|
| 218 |
+
return {
|
| 219 |
+
"name": product.get('product_name', food_name),
|
| 220 |
+
"brand": product.get('brands', 'Unknown'),
|
| 221 |
+
"nutrition": {
|
| 222 |
+
"calories": nutriments.get('energy-kcal_100g', 0),
|
| 223 |
+
"protein": nutriments.get('proteins_100g', 0),
|
| 224 |
+
"carbs": nutriments.get('carbohydrates_100g', 0),
|
| 225 |
+
"fat": nutriments.get('fat_100g', 0),
|
| 226 |
+
"fiber": nutriments.get('fiber_100g'),
|
| 227 |
+
"sugar": nutriments.get('sugars_100g'),
|
| 228 |
+
"sodium": nutriments.get('sodium_100g', 0) * 1000 if nutriments.get('sodium_100g') else None
|
| 229 |
+
},
|
| 230 |
+
"source": "Open Food Facts",
|
| 231 |
+
"serving_size": 100,
|
| 232 |
+
"serving_unit": "g"
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
except Exception as e:
|
| 236 |
+
logger.warning(f"⚠️ Nutrition search error: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
|
|
|
| 238 |
return get_estimated_nutrition(food_name)
|
| 239 |
|
| 240 |
+
|
| 241 |
def get_estimated_nutrition(food_name: str) -> Dict[str, Any]:
|
| 242 |
+
"""Vraća procijenjene nutritivne vrijednosti."""
|
| 243 |
food_lower = food_name.lower()
|
| 244 |
|
| 245 |
categories = {
|
|
|
|
| 251 |
'dairy': {'calories': 60, 'protein': 3.5, 'carbs': 5, 'fat': 3, 'fiber': 0, 'sugar': 5, 'sodium': 50},
|
| 252 |
'dessert': {'calories': 350, 'protein': 4, 'carbs': 50, 'fat': 15, 'fiber': 1, 'sugar': 40, 'sodium': 200},
|
| 253 |
'fast_food': {'calories': 250, 'protein': 12, 'carbs': 30, 'fat': 10, 'fiber': 2, 'sugar': 5, 'sodium': 600},
|
|
|
|
| 254 |
}
|
| 255 |
|
| 256 |
category_keywords = {
|
| 257 |
+
'fruit': ['apple', 'banana', 'orange', 'berry', 'fruit'],
|
| 258 |
+
'vegetable': ['salad', 'vegetable', 'tomato'],
|
| 259 |
+
'meat': ['chicken', 'beef', 'pork', 'steak', 'meat'],
|
| 260 |
+
'fish': ['fish', 'salmon', 'tuna', 'seafood'],
|
| 261 |
+
'grain': ['rice', 'pasta', 'noodle', 'bread'],
|
| 262 |
+
'dairy': ['cheese', 'yogurt', 'milk'],
|
| 263 |
+
'dessert': ['cake', 'cookie', 'chocolate', 'ice cream'],
|
| 264 |
+
'fast_food': ['burger', 'pizza', 'fries'],
|
|
|
|
| 265 |
}
|
| 266 |
|
| 267 |
detected_category = 'grain'
|
|
|
|
| 279 |
"source": "AI Estimation",
|
| 280 |
"serving_size": 100,
|
| 281 |
"serving_unit": "g",
|
| 282 |
+
"note": "Estimated values based on food category"
|
| 283 |
}
|
| 284 |
|
| 285 |
+
|
| 286 |
def is_image_file(file: UploadFile):
|
| 287 |
+
"""Provjerava da li je fajl slika."""
|
| 288 |
return file.content_type in ["image/jpeg", "image/png", "image/jpg", "image/webp"]
|
| 289 |
|
| 290 |
+
|
| 291 |
+
# --- Initialize Classifier ---
|
| 292 |
+
logger.info("🚀 Initializing Zero-Shot Food Recognition API...")
|
| 293 |
device = select_device()
|
| 294 |
+
logger.info(f"Using device: {device}")
|
|
|
|
| 295 |
|
| 296 |
+
classifier = ZeroShotFoodClassifier(device)
|
|
|
|
| 297 |
|
| 298 |
# --- FastAPI Application ---
|
| 299 |
app = FastAPI(
|
| 300 |
+
title="🎯 Zero-Shot Food Recognition API - CLIP Edition",
|
| 301 |
description="""
|
| 302 |
+
**Jednostavan i moćan food recognition sistem sa CLIP modelom**
|
| 303 |
+
|
| 304 |
+
### 🌟 Ključne mogućnosti:
|
| 305 |
+
- 🌍 **Zero-shot Learning** - Prepoznaje bilo šta bez dodatnog treninga
|
| 306 |
+
- 🎯 **Veliki spektar** - Ne samo hrana, već bilo koji objekat
|
| 307 |
+
- 🚀 **Jednostavan** - Clean i razumljiv kod
|
| 308 |
+
- 📊 **Pouzdan** - CLIP model sa state-of-the-art performansama
|
| 309 |
+
- 🏷️ **Fleksibilan** - Customizabilne kategorije
|
| 310 |
+
- ⚡ **Brz** - Optimizovana inferenca
|
| 311 |
+
|
| 312 |
+
### 📖 Kako CLIP radi:
|
| 313 |
+
CLIP je vision-language model koji razume vezu između slika i teksta.
|
| 314 |
+
Može prepoznati bilo koji objekat - samo mu kažeš šta da traži!
|
| 315 |
+
|
| 316 |
+
### 🎯 Primjena:
|
| 317 |
+
- Food recognition i nutrition tracking
|
| 318 |
+
- Općenita object detection
|
| 319 |
+
- Visual search
|
| 320 |
+
- Image classification za bilo koju domenu
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 321 |
""",
|
| 322 |
+
version="11.0.0"
|
| 323 |
)
|
| 324 |
|
| 325 |
+
# CORS
|
| 326 |
app.add_middleware(
|
| 327 |
CORSMiddleware,
|
| 328 |
allow_origins=["*"],
|
|
|
|
| 331 |
allow_headers=["*"],
|
| 332 |
)
|
| 333 |
|
| 334 |
+
|
| 335 |
@app.post("/analyze",
|
| 336 |
+
summary="🎯 Analyze Food Image",
|
| 337 |
+
description="Upload sliku za zero-shot food recognition"
|
|
|
|
| 338 |
)
|
| 339 |
async def analyze(file: UploadFile = File(...)):
|
| 340 |
"""
|
| 341 |
+
Analizira sliku i prepoznaje hranu koristeći CLIP zero-shot pristup.
|
| 342 |
|
| 343 |
+
Model automatski prepoznaje hranu iz Food-101 kategorija bez potrebe
|
| 344 |
+
za dodatnim treningom.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 345 |
"""
|
| 346 |
if not file:
|
| 347 |
+
raise HTTPException(status_code=400, detail="No image provided")
|
| 348 |
|
| 349 |
if not is_image_file(file):
|
| 350 |
+
raise HTTPException(status_code=400, detail="Unsupported image format. Use JPEG, PNG or WebP.")
|
|
|
|
|
|
|
|
|
|
| 351 |
|
| 352 |
try:
|
| 353 |
+
# Load image
|
| 354 |
contents = await file.read()
|
| 355 |
image = Image.open(BytesIO(contents))
|
| 356 |
|
|
|
|
| 357 |
if image.mode != "RGB":
|
| 358 |
image = image.convert("RGB")
|
| 359 |
|
|
|
|
| 360 |
image_width, image_height = image.size
|
| 361 |
+
|
| 362 |
except Exception as e:
|
| 363 |
+
raise HTTPException(status_code=500, detail=f"Error reading image: {e}")
|
| 364 |
|
| 365 |
try:
|
| 366 |
+
# Check if it's food
|
| 367 |
+
is_food, food_confidence = classifier.detect_if_food(image)
|
|
|
|
| 368 |
|
| 369 |
+
if not is_food and food_confidence > 0.6:
|
|
|
|
| 370 |
return JSONResponse(content={
|
| 371 |
"success": False,
|
| 372 |
"error": "Non-food object detected",
|
| 373 |
+
"message": "Image doesn't contain food. Please upload a food image.",
|
| 374 |
+
"confidence": food_confidence
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 375 |
})
|
| 376 |
|
| 377 |
+
# Classify food
|
| 378 |
+
logger.info("🔍 Classifying food...")
|
| 379 |
+
result = classifier.classify_food(image)
|
| 380 |
+
|
| 381 |
+
if result["confidence"] < MIN_CONFIDENCE:
|
| 382 |
raise HTTPException(
|
| 383 |
status_code=422,
|
| 384 |
+
detail=f"Low confidence ({result['confidence']:.2%}). Please upload a clearer image."
|
| 385 |
)
|
| 386 |
+
|
| 387 |
except HTTPException:
|
| 388 |
raise
|
| 389 |
except Exception as e:
|
| 390 |
+
logger.error(f"Classification error: {e}")
|
| 391 |
+
raise HTTPException(status_code=500, detail=f"Classification error: {e}")
|
| 392 |
|
| 393 |
# Get nutrition data
|
| 394 |
+
logger.info(f"🍎 Recognized food: {result['primary_label']}")
|
| 395 |
+
nutrition_data = search_nutrition_data(result["primary_label"])
|
|
|
|
|
|
|
|
|
|
| 396 |
|
| 397 |
+
# Prepare response
|
| 398 |
+
response = {
|
| 399 |
"success": True,
|
| 400 |
+
"label": result["primary_label"],
|
| 401 |
+
"confidence": result["confidence"],
|
| 402 |
+
"alternatives": result["alternatives"],
|
| 403 |
|
| 404 |
+
# Nutrition
|
| 405 |
"nutrition": nutrition_data["nutrition"],
|
| 406 |
"source": nutrition_data["source"],
|
| 407 |
|
| 408 |
+
# Image info
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
"image_info": {
|
| 410 |
"width": image_width,
|
| 411 |
"height": image_height,
|
| 412 |
"format": image.format
|
| 413 |
},
|
| 414 |
|
| 415 |
+
# Model info
|
| 416 |
"model_info": {
|
| 417 |
+
"type": "Zero-Shot CLIP Classifier",
|
| 418 |
+
"model": CLIP_MODEL_NAME,
|
| 419 |
+
"version": "11.0.0",
|
| 420 |
+
"method": "Zero-shot learning",
|
| 421 |
+
"categories": len(FOOD_CATEGORIES),
|
| 422 |
+
"device": device
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 423 |
}
|
| 424 |
}
|
| 425 |
|
| 426 |
+
return JSONResponse(content=response)
|
| 427 |
+
|
| 428 |
|
| 429 |
+
@app.post("/analyze-custom",
|
| 430 |
+
summary="🎯 Analyze with Custom Categories",
|
| 431 |
+
description="Upload sliku i definiši custom kategorije za prepoznavanje"
|
| 432 |
)
|
| 433 |
+
async def analyze_custom(
|
| 434 |
+
file: UploadFile = File(...),
|
| 435 |
+
categories: str = None
|
| 436 |
+
):
|
| 437 |
+
"""
|
| 438 |
+
Zero-shot analiza sa custom kategorijama.
|
| 439 |
+
|
| 440 |
+
Primjer: categories="pizza,burger,pasta,salad"
|
| 441 |
+
|
| 442 |
+
Ovo demonstrira moć CLIP-a - može prepoznati bilo šta što mu kažeš!
|
| 443 |
+
"""
|
| 444 |
+
if not file:
|
| 445 |
+
raise HTTPException(status_code=400, detail="No image provided")
|
| 446 |
+
|
| 447 |
+
if not is_image_file(file):
|
| 448 |
+
raise HTTPException(status_code=400, detail="Unsupported image format")
|
| 449 |
+
|
| 450 |
+
# Parse categories
|
| 451 |
+
custom_categories = None
|
| 452 |
+
if categories:
|
| 453 |
+
custom_categories = [cat.strip() for cat in categories.split(",")]
|
| 454 |
+
logger.info(f"Using custom categories: {custom_categories}")
|
| 455 |
+
|
| 456 |
try:
|
| 457 |
+
contents = await file.read()
|
| 458 |
+
image = Image.open(BytesIO(contents))
|
| 459 |
|
| 460 |
+
if image.mode != "RGB":
|
| 461 |
+
image = image.convert("RGB")
|
| 462 |
|
| 463 |
+
except Exception as e:
|
| 464 |
+
raise HTTPException(status_code=500, detail=f"Error reading image: {e}")
|
| 465 |
+
|
| 466 |
+
try:
|
| 467 |
+
result = classifier.classify_food(image, custom_categories=custom_categories)
|
| 468 |
|
| 469 |
return JSONResponse(content={
|
| 470 |
"success": True,
|
| 471 |
+
"label": result["primary_label"],
|
| 472 |
+
"confidence": result["confidence"],
|
| 473 |
+
"top5": result["top5"],
|
| 474 |
+
"categories_used": custom_categories if custom_categories else "Food-101 default",
|
| 475 |
+
"model_info": {
|
| 476 |
+
"type": "Zero-Shot CLIP Classifier",
|
| 477 |
+
"model": CLIP_MODEL_NAME,
|
| 478 |
+
"method": "Custom zero-shot classification"
|
| 479 |
+
}
|
| 480 |
})
|
| 481 |
|
|
|
|
|
|
|
| 482 |
except Exception as e:
|
| 483 |
+
logger.error(f"Classification error: {e}")
|
| 484 |
+
raise HTTPException(status_code=500, detail=f"Classification error: {e}")
|
| 485 |
+
|
|
|
|
|
|
|
| 486 |
|
| 487 |
+
@app.get("/",
|
| 488 |
+
summary="🎯 API Info",
|
| 489 |
+
description="Informacije o Zero-Shot Food Recognition API-ju"
|
| 490 |
)
|
| 491 |
def root():
|
| 492 |
+
"""Root endpoint sa API informacijama."""
|
| 493 |
return {
|
| 494 |
+
"message": "🎯 Zero-Shot Food Recognition API - CLIP Edition",
|
| 495 |
+
"status": "🟢 Online & Ready",
|
| 496 |
+
"tagline": "Jednostavan i moćan food recognition sa zero-shot learning",
|
| 497 |
"model": {
|
| 498 |
+
"name": CLIP_MODEL_NAME,
|
| 499 |
+
"type": "Vision-Language Model (CLIP)",
|
| 500 |
+
"capabilities": "Zero-shot classification",
|
|
|
|
|
|
|
| 501 |
"device": device.upper(),
|
| 502 |
+
"food_categories": len(FOOD_CATEGORIES)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 503 |
},
|
| 504 |
+
"features": {
|
| 505 |
+
"zero_shot": "✅ Prepoznaje bilo šta bez dodatnog treninga",
|
| 506 |
+
"customizable": "✅ Customizabilne kategorije",
|
| 507 |
+
"fast": "✅ Brza inferenca",
|
| 508 |
+
"simple": "✅ Jednostavan i čist kod",
|
| 509 |
+
"nutrition": "✅ Automatski nutrition lookup",
|
| 510 |
+
"open_source": "✅ 100% open-source"
|
| 511 |
},
|
| 512 |
"endpoints": {
|
| 513 |
+
"POST /analyze": "🎯 Standard food analysis (Food-101 categories)",
|
| 514 |
+
"POST /analyze-custom": "🎨 Custom category analysis",
|
| 515 |
+
"GET /health": "💚 Health check",
|
| 516 |
+
"GET /categories": "📋 List all food categories"
|
| 517 |
},
|
| 518 |
+
"about_clip": {
|
| 519 |
+
"what_is_clip": "CLIP (Contrastive Language-Image Pre-training) je model koji razume vezu između slika i teksta",
|
| 520 |
+
"zero_shot": "Može prepoznati bilo šta - samo mu kažeš šta da traži!",
|
| 521 |
+
"trained_on": "400+ miliona image-text parova sa interneta",
|
| 522 |
+
"advantages": [
|
| 523 |
+
"Prepoznaje širok spektar objekata",
|
| 524 |
+
"Nema potrebe za dodatnim treningom",
|
| 525 |
+
"Fleksibilan - radi sa bilo kojim kategorijama",
|
| 526 |
+
"State-of-the-art performanse"
|
| 527 |
+
]
|
| 528 |
+
}
|
| 529 |
}
|
| 530 |
|
| 531 |
+
|
| 532 |
+
@app.get("/health",
|
| 533 |
+
summary="💚 Health Check",
|
| 534 |
+
description="Provjeri status sistema"
|
| 535 |
)
|
| 536 |
def health_check():
|
| 537 |
+
"""Health check endpoint."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
try:
|
| 539 |
+
model_loaded = classifier.model is not None
|
| 540 |
+
|
| 541 |
+
# Test nutrition API
|
| 542 |
+
nutrition_api_status = "unknown"
|
| 543 |
+
try:
|
| 544 |
+
test_response = requests.get(
|
| 545 |
+
"https://world.openfoodfacts.org/api/v0/product/737628064502.json",
|
| 546 |
+
timeout=3
|
| 547 |
+
)
|
| 548 |
+
nutrition_api_status = "healthy" if test_response.status_code == 200 else "degraded"
|
| 549 |
+
except:
|
| 550 |
+
nutrition_api_status = "offline"
|
| 551 |
+
|
| 552 |
+
return {
|
| 553 |
+
"status": "healthy" if model_loaded else "unhealthy",
|
| 554 |
+
"version": "11.0.0 - ZERO-SHOT CLIP EDITION",
|
| 555 |
+
"model": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
"name": CLIP_MODEL_NAME,
|
| 557 |
+
"loaded": model_loaded,
|
| 558 |
+
"device": device,
|
| 559 |
+
"type": "Zero-shot CLIP"
|
| 560 |
+
},
|
| 561 |
+
"nutrition_api": nutrition_api_status,
|
| 562 |
+
"capabilities": {
|
| 563 |
+
"food_recognition": model_loaded,
|
| 564 |
+
"zero_shot_classification": model_loaded,
|
| 565 |
+
"custom_categories": model_loaded,
|
| 566 |
+
"nutrition_lookup": nutrition_api_status in ["healthy", "degraded"]
|
| 567 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 568 |
}
|
| 569 |
+
except Exception as e:
|
| 570 |
+
return {
|
| 571 |
+
"status": "error",
|
| 572 |
+
"error": str(e)
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
|
| 576 |
+
@app.get("/categories",
|
| 577 |
+
summary="📋 List Food Categories",
|
| 578 |
+
description="Lista svih dostupnih food kategorija"
|
| 579 |
)
|
| 580 |
+
def get_categories():
|
| 581 |
+
"""Vraća listu svih Food-101 kategorija."""
|
| 582 |
return {
|
| 583 |
+
"total": len(FOOD_CATEGORIES),
|
| 584 |
+
"categories": sorted(FOOD_CATEGORIES),
|
| 585 |
+
"note": "You can also use custom categories with /analyze-custom endpoint"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
}
|
| 587 |
|
| 588 |
+
|
| 589 |
+
# --- Run API ---
|
| 590 |
if __name__ == "__main__":
|
| 591 |
+
print("=" * 80)
|
| 592 |
+
print("🎯 ZERO-SHOT FOOD RECOGNITION API - CLIP EDITION")
|
| 593 |
+
print("=" * 80)
|
| 594 |
+
print("🌟 Features:")
|
| 595 |
+
print(" ✅ Zero-shot learning - prepoznaje bilo šta!")
|
| 596 |
+
print(" ✅ CLIP model - state-of-the-art performanse")
|
| 597 |
+
print(" ✅ Jednostavan kod - lako razumljiv i održiv")
|
| 598 |
+
print(" ✅ Customizabilne kategorije")
|
| 599 |
+
print(" ✅ Automatski nutrition lookup")
|
| 600 |
+
print("=" * 80)
|
| 601 |
+
print(f"🤖 Model: {CLIP_MODEL_NAME}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
print(f"💻 Device: {device.upper()}")
|
| 603 |
+
print(f"🏷️ Categories: {len(FOOD_CATEGORIES)} (Food-101)")
|
| 604 |
+
print("=" * 80)
|
|
|
|
| 605 |
|
| 606 |
run_port = int(os.environ.get("PORT", "8000"))
|
| 607 |
+
print(f"🌍 Server: http://0.0.0.0:{run_port}")
|
| 608 |
+
print(f"📚 Docs: http://0.0.0.0:{run_port}/docs")
|
| 609 |
+
print("=" * 80)
|
|
|
|
| 610 |
|
| 611 |
uvicorn.run(app, host="0.0.0.0", port=run_port)
|
requirements.txt
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
-
#
|
| 2 |
-
#
|
| 3 |
|
| 4 |
# Core API Framework
|
| 5 |
fastapi==0.115.0
|
|
@@ -8,31 +8,16 @@ python-multipart==0.0.12
|
|
| 8 |
|
| 9 |
# Image Processing
|
| 10 |
pillow==11.0.0
|
| 11 |
-
opencv-python-headless==4.10.0.84
|
| 12 |
|
| 13 |
-
# Deep Learning
|
| 14 |
-
# NOTE: Due to CVE-2025-32434, torch must be >=2.6 to allow torch.load() via transformers
|
| 15 |
torch>=2.6.0
|
| 16 |
torchvision>=0.19.0
|
| 17 |
-
safetensors>=0.4.3
|
| 18 |
|
| 19 |
-
# Transformers
|
| 20 |
transformers>=4.44.2
|
| 21 |
-
timm>=1.0.9
|
| 22 |
|
| 23 |
-
#
|
| 24 |
-
albumentations>=1.4.15
|
| 25 |
-
|
| 26 |
-
# HTTP util
|
| 27 |
requests>=2.32.0
|
| 28 |
|
| 29 |
-
#
|
| 30 |
-
|
| 31 |
-
scipy>=1.11.0
|
| 32 |
-
|
| 33 |
-
# Additional ML utilities
|
| 34 |
-
scikit-learn>=1.3.0
|
| 35 |
-
|
| 36 |
-
# Napomena: ULTRA varijanta koristi ensemble pristup sa specijalizovanim modelima
|
| 37 |
-
# za maksimalnu preciznost u food recognition
|
| 38 |
-
|
|
|
|
| 1 |
+
# Zero-Shot Food Recognition API - CLIP Edition
|
| 2 |
+
# Minimalni requirements za jednostavan i moćan food recognition
|
| 3 |
|
| 4 |
# Core API Framework
|
| 5 |
fastapi==0.115.0
|
|
|
|
| 8 |
|
| 9 |
# Image Processing
|
| 10 |
pillow==11.0.0
|
|
|
|
| 11 |
|
| 12 |
+
# Deep Learning - PyTorch sa CVE fix
|
|
|
|
| 13 |
torch>=2.6.0
|
| 14 |
torchvision>=0.19.0
|
|
|
|
| 15 |
|
| 16 |
+
# Transformers za CLIP model
|
| 17 |
transformers>=4.44.2
|
|
|
|
| 18 |
|
| 19 |
+
# HTTP za nutrition API
|
|
|
|
|
|
|
|
|
|
| 20 |
requests>=2.32.0
|
| 21 |
|
| 22 |
+
# Napomena: Ovaj setup koristi samo CLIP model za zero-shot classification
|
| 23 |
+
# što je jednostavnije i dovoljno moćno za većinu use-case-ova
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|