jatinbalani Cursor commited on
Commit
043df84
·
1 Parent(s): 3d34258

Add FitGenie CalorieCLIP API for HF Spaces deployment.

Browse files

FastAPI /predict endpoint with pre-downloaded CalorieCLIP weights at Docker build time.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (6) hide show
  1. Dockerfile +28 -0
  2. README.md +167 -6
  3. app.py +61 -0
  4. gradio_app.py +36 -0
  5. model_core.py +96 -0
  6. requirements.txt +9 -0
Dockerfile ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PIP_NO_CACHE_DIR=1 \
6
+ HF_HOME=/app/.cache/huggingface
7
+
8
+ RUN apt-get update && apt-get install -y --no-install-recommends \
9
+ libgl1 \
10
+ libglib2.0-0 \
11
+ && rm -rf /var/lib/apt/lists/*
12
+
13
+ WORKDIR /app
14
+
15
+ COPY requirements.txt .
16
+ RUN pip install --no-cache-dir -r requirements.txt
17
+
18
+ COPY model_core.py app.py gradio_app.py .
19
+
20
+ # Pre-download weights at build time so cold starts are faster.
21
+ RUN python -c "from huggingface_hub import hf_hub_download; hf_hub_download('jc-builds/CalorieCLIP', 'calorie_clip.pt')"
22
+
23
+ EXPOSE 8000
24
+
25
+ HEALTHCHECK --interval=30s --timeout=5s --start-period=120s --retries=3 \
26
+ CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"
27
+
28
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
README.md CHANGED
@@ -1,12 +1,173 @@
1
  ---
2
- title: Fitgenie Calorie Clip
3
- emoji: 📈
4
- colorFrom: yellow
5
- colorTo: yellow
6
  sdk: docker
 
7
  pinned: false
8
  license: mit
9
- short_description: Instant food calorie estimate API for FitGenie(~51 MAE, CPU)
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: FitGenie CalorieCLIP
3
+ emoji: 🍎
4
+ colorFrom: green
5
+ colorTo: blue
6
  sdk: docker
7
+ app_port: 8000
8
  pinned: false
9
  license: mit
10
+ short_description: Instant food calorie estimate API for FitGenie (~51 MAE)
11
  ---
12
 
13
+ # FitGenie CalorieCLIP
14
+
15
+ Free-to-host **instant calorie estimate** from food photos for the [FitGenie](https://github.com) nutrition app.
16
+
17
+ | | |
18
+ |---|---|
19
+ | **Model** | [jc-builds/CalorieCLIP](https://huggingface.co/jc-builds/CalorieCLIP) (CLIP ViT-B/32 + regression) |
20
+ | **Output** | Calories only (not protein/carbs/fat) |
21
+ | **Speed** | ~50–200 ms on CPU |
22
+ | **Cost** | **$0** on Hugging Face Spaces free tier |
23
+
24
+ ---
25
+
26
+ ## One-click deploy on Hugging Face (free)
27
+
28
+ ### Step 1 — Create the Space
29
+
30
+ 1. Go to **[huggingface.co/new-space](https://huggingface.co/new-space)**
31
+ 2. Fill in:
32
+ - **Owner:** your username
33
+ - **Space name:** `fitgenie-calorie-clip` (or any name)
34
+ - **License:** MIT
35
+ - **SDK:** **Docker**
36
+ - **Hardware:** **CPU basic** (free — 16 GB RAM)
37
+ 3. Click **Create Space**
38
+
39
+ ### Step 2 — Upload these files
40
+
41
+ Upload the contents of `fitgenie_calorie_clip/` to the Space repo:
42
+
43
+ ```
44
+ app.py
45
+ model_core.py
46
+ gradio_app.py
47
+ Dockerfile
48
+ requirements.txt
49
+ README.md ← this file (frontmatter configures the Space)
50
+ ```
51
+
52
+ **Or** connect your GitHub repo and set the Space to sync from `fitgenie_calorie_clip/`.
53
+
54
+ ### Step 3 — Wait for build
55
+
56
+ First build takes **5–15 minutes** (downloads CLIP + CalorieCLIP weights).
57
+
58
+ When status is **Running**, your API base URL is:
59
+
60
+ ```
61
+ https://YOUR-USERNAME-fitgenie-calorie-clip.hf.space
62
+ ```
63
+
64
+ ### Step 4 — Test
65
+
66
+ ```bash
67
+ curl https://YOUR-USERNAME-fitgenie-calorie-clip.hf.space/health
68
+ # → {"status":"ok","model":"CalorieCLIP"}
69
+
70
+ curl -X POST https://YOUR-USERNAME-fitgenie-calorie-clip.hf.space/predict \
71
+ -H "Content-Type: application/json" \
72
+ -d '{"image":"'$(base64 -i food.jpg | tr -d '\n')'"}'
73
+ # → {"calories":342}
74
+ ```
75
+
76
+ ### Step 5 — Wire to FitGenie backend
77
+
78
+ On **Render** → your `fitgenie-backend` service → **Environment**:
79
+
80
+ ```bash
81
+ CALORIE_CLIP_URL=https://YOUR-USERNAME-fitgenie-calorie-clip.hf.space/predict
82
+ CALORIE_CLIP_API_KEY=choose-a-secret-string
83
+ FOOD_ANALYSIS_PROVIDER=gpt
84
+ OPENAI_API_KEY=sk-...
85
+ ```
86
+
87
+ On the **HF Space** → **Settings** → **Variables**:
88
+
89
+ ```bash
90
+ CALORIE_CLIP_API_KEY=same-secret-as-backend
91
+ ```
92
+
93
+ Redeploy backend. Photo flow will show real kcal estimates instead of ~400 fallback.
94
+
95
+ ### Step 6 — Keep Space awake (optional, free)
96
+
97
+ Free Spaces sleep after **48 hours** of no traffic.
98
+
99
+ 1. Sign up at [uptimerobot.com](https://uptimerobot.com) (free)
100
+ 2. Add monitor: URL `https://YOUR-SPACE.hf.space/health`, interval **30 min**
101
+
102
+ ---
103
+
104
+ ## Optional: Gradio demo Space (browser UI)
105
+
106
+ For a **visual demo** (no API), create a second Space:
107
+
108
+ | Setting | Value |
109
+ |---------|-------|
110
+ | SDK | **Gradio** |
111
+ | Hardware | CPU basic |
112
+ | App file | `gradio_app.py` |
113
+
114
+ Use this README frontmatter instead:
115
+
116
+ ```yaml
117
+ ---
118
+ title: FitGenie CalorieCLIP Demo
119
+ sdk: gradio
120
+ sdk_version: 4.44.1
121
+ app_file: gradio_app.py
122
+ hardware: cpu-basic
123
+ ---
124
+ ```
125
+
126
+ ---
127
+
128
+ ## API reference
129
+
130
+ | Endpoint | Method | Body | Response |
131
+ |----------|--------|------|----------|
132
+ | `/health` | GET | — | `{ "status": "ok", "model": "CalorieCLIP" }` |
133
+ | `/predict` | POST | `{ "image": "<base64>" }` | `{ "calories": 342 }` |
134
+
135
+ Optional header: `X-API-Key: your-secret` (when `CALORIE_CLIP_API_KEY` is set).
136
+
137
+ ---
138
+
139
+ ## Environment variables
140
+
141
+ | Variable | Default | Description |
142
+ |----------|---------|-------------|
143
+ | `CALORIE_CLIP_API_KEY` | (empty) | Require `X-API-Key` header |
144
+ | `CALORIE_CLIP_MODEL_REPO` | `jc-builds/CalorieCLIP` | HuggingFace weights repo |
145
+ | `MAX_IMAGE_BYTES` | `8388608` | Max upload size (8 MB) |
146
+
147
+ ---
148
+
149
+ ## Limits (free tier)
150
+
151
+ | Topic | Detail |
152
+ |-------|--------|
153
+ | Indian food | Weak — trained on US cafeteria + 2 Indian Food-101 classes |
154
+ | Macros | **Not supported** — use GPT-4o or `fitgenie_food_analysis` for P/C/F |
155
+ | Thali / multi-item | Poor — single calorie number for whole image |
156
+ | Sleep | After 48h idle — use UptimeRobot |
157
+
158
+ ---
159
+
160
+ ## Local dev
161
+
162
+ ```bash
163
+ pip install -r requirements.txt
164
+ uvicorn app:app --reload --port 8000
165
+ # or visual demo:
166
+ python gradio_app.py
167
+ ```
168
+
169
+ ---
170
+
171
+ ## License
172
+
173
+ MIT (service code) · CalorieCLIP model MIT · FitGenie integration
app.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ CalorieCLIP API for FitGenie backend.
3
+
4
+ POST /predict { "image": "<base64>" } -> { "calories": 342 }
5
+ GET /health -> { "status": "ok" }
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from contextlib import asynccontextmanager
12
+
13
+ from fastapi import FastAPI, Header, HTTPException
14
+ from pydantic import BaseModel, Field
15
+
16
+ from model_core import decode_base64_image, load_models, predict_calories
17
+
18
+ API_KEY = os.getenv("CALORIE_CLIP_API_KEY", "").strip()
19
+
20
+
21
+ def _require_api_key(x_api_key: str | None) -> None:
22
+ if not API_KEY:
23
+ return
24
+ if x_api_key != API_KEY:
25
+ raise HTTPException(status_code=401, detail="Invalid API key")
26
+
27
+
28
+ @asynccontextmanager
29
+ async def lifespan(_: FastAPI):
30
+ load_models()
31
+ yield
32
+
33
+
34
+ app = FastAPI(title="FitGenie CalorieCLIP", version="1.1.0", lifespan=lifespan)
35
+
36
+
37
+ class PredictRequest(BaseModel):
38
+ image: str = Field(..., description="Base64-encoded food photo")
39
+
40
+
41
+ @app.get("/")
42
+ def root() -> dict[str, str]:
43
+ return {"service": "FitGenie CalorieCLIP", "endpoints": "/health, /predict"}
44
+
45
+
46
+ @app.get("/health")
47
+ def health() -> dict[str, str]:
48
+ return {"status": "ok", "model": "CalorieCLIP"}
49
+
50
+
51
+ @app.post("/predict")
52
+ def predict(
53
+ req: PredictRequest,
54
+ x_api_key: str | None = Header(default=None, alias="X-API-Key"),
55
+ ) -> dict[str, int]:
56
+ _require_api_key(x_api_key)
57
+ try:
58
+ image = decode_base64_image(req.image)
59
+ except ValueError as exc:
60
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
61
+ return {"calories": predict_calories(image)}
gradio_app.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Gradio demo for Hugging Face Spaces (SDK: gradio).
3
+ Upload a food photo → see estimated calories in the browser.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import gradio as gr
9
+
10
+ from model_core import load_models, predict_calories
11
+
12
+ load_models()
13
+
14
+
15
+ def estimate(image) -> str:
16
+ if image is None:
17
+ return "Upload a food photo to get started."
18
+ calories = predict_calories(image)
19
+ return f"**Estimated calories:** ~{calories} kcal\n\n*Preview only — FitGenie uses GPT/VLM for full protein & macros.*"
20
+
21
+
22
+ demo = gr.Interface(
23
+ fn=estimate,
24
+ inputs=gr.Image(type="pil", label="Food photo"),
25
+ outputs=gr.Markdown(label="Calorie estimate"),
26
+ title="FitGenie CalorieCLIP",
27
+ description=(
28
+ "Instant calorie estimate from a food photo (~51 kcal MAE on Western cafeteria food). "
29
+ "Works best on single-plate photos. For Indian thali / full macros, use FitGenie app with GPT analysis."
30
+ ),
31
+ examples=None,
32
+ allow_flagging="never",
33
+ )
34
+
35
+ if __name__ == "__main__":
36
+ demo.launch()
model_core.py ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared CalorieCLIP loading and inference."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import io
7
+ import logging
8
+ import os
9
+ from typing import Any
10
+
11
+ import open_clip
12
+ import torch
13
+ import torch.nn as nn
14
+ from huggingface_hub import hf_hub_download
15
+ from PIL import Image
16
+
17
+ logger = logging.getLogger("calorie_clip")
18
+
19
+ MODEL_REPO = os.getenv("CALORIE_CLIP_MODEL_REPO", "jc-builds/CalorieCLIP")
20
+ WEIGHTS_FILE = os.getenv("CALORIE_CLIP_WEIGHTS_FILE", "calorie_clip.pt")
21
+ MAX_IMAGE_BYTES = int(os.getenv("MAX_IMAGE_BYTES", str(8 * 1024 * 1024)))
22
+
23
+ _state: dict[str, Any] = {}
24
+
25
+
26
+ class RegressionHead(nn.Module):
27
+ def __init__(self) -> None:
28
+ super().__init__()
29
+ self.net = nn.Sequential(
30
+ nn.Linear(512, 512),
31
+ nn.BatchNorm1d(512),
32
+ nn.ReLU(),
33
+ nn.Dropout(0.4),
34
+ nn.Linear(512, 256),
35
+ nn.BatchNorm1d(256),
36
+ nn.ReLU(),
37
+ nn.Dropout(0.3),
38
+ nn.Linear(256, 64),
39
+ nn.ReLU(),
40
+ nn.Linear(64, 1),
41
+ )
42
+
43
+ def forward(self, x: torch.Tensor) -> torch.Tensor:
44
+ return self.net(x)
45
+
46
+
47
+ def load_models() -> None:
48
+ if _state.get("ready"):
49
+ return
50
+ logger.info("Loading CalorieCLIP from %s/%s", MODEL_REPO, WEIGHTS_FILE)
51
+ clip_model, _, preprocess = open_clip.create_model_and_transforms(
52
+ "ViT-B-32",
53
+ pretrained="openai",
54
+ )
55
+ weights_path = hf_hub_download(repo_id=MODEL_REPO, filename=WEIGHTS_FILE)
56
+ checkpoint = torch.load(weights_path, map_location="cpu", weights_only=False)
57
+ clip_model.load_state_dict(checkpoint["clip_state"], strict=False)
58
+ head = RegressionHead()
59
+ head.load_state_dict(checkpoint["regressor_state"])
60
+ clip_model.eval()
61
+ head.eval()
62
+
63
+ _state["clip"] = clip_model
64
+ _state["head"] = head
65
+ _state["preprocess"] = preprocess
66
+ _state["ready"] = True
67
+ logger.info("CalorieCLIP ready")
68
+
69
+
70
+ def decode_image_bytes(raw: bytes) -> Image.Image:
71
+ if len(raw) > MAX_IMAGE_BYTES:
72
+ raise ValueError("Image too large")
73
+ return Image.open(io.BytesIO(raw)).convert("RGB")
74
+
75
+
76
+ def decode_base64_image(b64: str) -> Image.Image:
77
+ cleaned = b64.strip()
78
+ if cleaned.startswith("data:"):
79
+ cleaned = cleaned.split(",", 1)[1]
80
+ try:
81
+ raw = base64.b64decode(cleaned, validate=True)
82
+ except Exception as exc:
83
+ raise ValueError("Invalid base64 image") from exc
84
+ return decode_image_bytes(raw)
85
+
86
+
87
+ def predict_calories(image: Image.Image) -> int:
88
+ load_models()
89
+ clip_model = _state["clip"]
90
+ head = _state["head"]
91
+ preprocess = _state["preprocess"]
92
+ tensor = preprocess(image).unsqueeze(0)
93
+ with torch.no_grad():
94
+ features = clip_model.encode_image(tensor)
95
+ calories = float(head(features).item())
96
+ return round(max(0.0, calories))
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ open-clip-torch==2.26.1
4
+ torch==2.4.1
5
+ torchvision==0.19.1
6
+ pillow==10.4.0
7
+ huggingface-hub==0.24.7
8
+ pydantic==2.9.2
9
+ gradio==4.44.1