Fifafan1 commited on
Commit
d6699bd
·
verified ·
1 Parent(s): 641c34f

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +502 -264
app.py CHANGED
@@ -1,334 +1,572 @@
1
- import os, re, json, math, base64
2
- from io import BytesIO
3
- from typing import Any, Dict, List, Optional, Tuple
4
 
5
  import httpx
6
- import gradio as gr
7
  import pandas as pd
 
8
  from PIL import Image
9
 
10
  # =========================
11
- # Config
12
  # =========================
13
- HF_TOKEN = os.getenv("HF_TOKEN") # Space → Settings → Repository secrets (Read)
14
- OFF_BASE = "https://world.openfoodfacts.org"
15
 
16
- # Vision-LLM candidates (Inference API). Ще пробваме по ред:
17
- VLLM_MODELS = [
18
- os.getenv("HF_VISION_LLM", "").strip() or "llava-hf/llava-1.5-7b-hf",
19
- "llava-hf/llava-1.5-13b-hf",
20
- "Qwen/Qwen2-VL-7B-Instruct",
21
  ]
22
 
23
- # Показни имена (EN→BG)
24
- EN_BG = {
25
- "chicken breast": "пилешко филе","beef":"говеждо","steak":"пържола","pork":"свинско","turkey":"пуешко","lamb":"агнешко",
26
- "bacon":"бекон","ham":"шунка","sausage":"наденица","prosciutto":"прошуто",
27
- "salmon":"сьомга","tuna":"тон","shrimp":"скариди","prawn":"скариди","fish":"риба",
28
- "egg":"яйце","egg yolk":"жълтък","egg white":"белтък","tofu":"тофу","tempeh":"темпе",
29
- "rice":"ориз","white rice":"бял ориз","brown rice":"кафяв ориз","pasta":"паста","spaghetti":"спагети","noodles":"нудли",
30
- "bread":"хляб","bun":"питка","tortilla":"тортила","potato":"картоф","fries":"пържени картофи",
31
- "tomato":"домати","onion":"лук","red onion":"червен лук","garlic":"чесън","bell pepper":"чушка","cucumber":"краставица",
32
- "lettuce":"маруля","spinach":"спанак","mushroom":"гъби","broccoli":"броколи","carrot":"морков","corn":"царевица",
33
- "peas":"грах","eggplant":"патладжан","zucchini":"тиквичка","cabbage":"зеле","pickle":"краставички",
34
- "mozzarella":"моцарела","cheddar":"чедър","parmesan":"пармезан","feta":"фета","yogurt":"кисело мляко","milk":"прясно мляко",
35
- "butter":"масло","cream":"сметана","mayonnaise":"майонеза","ketchup":"кетчуп","mustard":"горчица",
36
- "tomato sauce":"доматен сос","pesto":"песто","soy sauce":"соев сос","olive":"маслина","olive oil":"зехтин",
37
- "sunflower oil":"олио","sirene":"сирене","kashkaval":"кашкавал","lyutenitsa":"лютеница","olives":"маслини"
38
- }
39
 
40
- EMPTY_DF = pd.DataFrame(columns=["Съставка","Грамаж (g)","Ккал","Белтъчини (g)","Въглехидрати (g)","Мазнини (g)"])
41
 
42
- # =========================
43
- # Helpers
44
- # =========================
45
- def _num(v: Any) -> float:
46
- try:
47
- x = float(v); return x if math.isfinite(x) else 0.0
48
- except Exception:
49
- return 0.0
50
-
51
- def _kcal_from_macros_per100(p100: float, c100: float, f100: float) -> float:
52
- return p100*4 + c100*4 + f100*9
53
-
54
- def _scale_per100_to_grams(per100: Dict[str,float], grams: float) -> Dict[str,float]:
55
- f = grams/100.0
56
- k = per100.get("kcal100",0.0)
57
- p = per100.get("protein100",0.0)
58
- c = per100.get("carbs100",0.0)
59
- fa= per100.get("fat100",0.0)
60
- if not k and (p or c or fa): k = _kcal_from_macros_per100(p,c,fa)
61
- return {
62
- "ккал": round(k*f,1) if k else 0.0,
63
- "белтъчини": round(p*f,1) if p else 0.0,
64
- "въглехидрати": round(c*f,1) if c else 0.0,
65
- "мазнини": round(fa*f,1) if fa else 0.0,
66
- }
67
 
68
- def _b64_image(img: Image.Image) -> str:
69
- buf = BytesIO(); img.save(buf, format="PNG")
70
- return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode("ascii")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
 
72
- def _hf_url(model_id: str) -> str:
73
- return f"https://api-inference.huggingface.co/models/{model_id}"
74
 
75
  # =========================
76
- # Vision LLM (robust callers; try multiple payload shapes)
77
  # =========================
78
- SYSTEM_PROMPT = (
79
- "You are a culinary vision assistant. "
80
- "Given an image, extract: (1) DISH name (short), (2) INGREDIENTS list. "
81
- "Return STRICT JSON with this schema:\n"
82
- "{\"dish\": \"...\", \"ingredients\": [{\"name\": \"...\"}, ...]}\n"
83
- "Rules: use common food terms in EN; avoid brand names; 3-12 concise ingredients; "
84
- "no commentary, JSON only."
85
- )
86
-
87
- def _payloads_for_model(b64_img: str, user_prompt: str) -> List[Dict[str, Any]]:
88
- # 1) LLaVA-style
89
- p1 = {
90
- "inputs": {
91
- "prompt": f"USER: <image>\n{user_prompt}\nASSISTANT:",
92
- "image": b64_img
93
- },
94
- "parameters": {"max_new_tokens": 256}
95
- }
96
- # 2) Messages (Qwen2-VL style)
97
- p2 = {
98
- "inputs": [
99
- {"role":"system","content":[{"type":"text","text":SYSTEM_PROMPT}]},
100
- {"role":"user","content":[{"type":"image","image":b64_img},{"type":"text","text":user_prompt}]}
101
- ],
102
- "parameters": {"max_new_tokens": 256}
103
- }
104
- # 3) Generic {image, text}
105
- p3 = {
106
- "inputs": {"image": b64_img, "text": user_prompt},
107
- "parameters": {"max_new_tokens": 256}
108
- }
109
- return [p1, p2, p3]
110
 
111
- def _parse_llm_json(text: str) -> Tuple[str, List[str]]:
112
- """Parse {"dish": "...", "ingredients":[{"name":"..."}, ...]}."""
113
- try:
114
- # pick first {...} block to avoid stray tokens
115
- m = re.search(r"\{.*\}", text, re.S)
116
- js = json.loads(m.group(0) if m else text)
117
- dish = str(js.get("dish") or "").strip()
118
- ings = []
119
- for it in js.get("ingredients") or []:
120
- name = str(it.get("name") or "").strip()
121
- if name: ings.append(name)
122
- # fallback: if ingredients is a list of strings
123
- if not ings and isinstance(js.get("ingredients"), list):
124
- ings = [str(x).strip() for x in js["ingredients"] if str(x).strip()]
125
- return dish, ings
126
- except Exception:
127
- return "", []
128
-
129
- async def vllm_extract(image: Image.Image) -> Tuple[str, List[str], str]:
130
- """
131
- Returns (dish, ingredients[], used_model). Never raises.
132
- """
133
  if not HF_TOKEN:
134
- return "", [], ""
135
- b64 = _b64_image(image)
136
  headers = {
137
  "Authorization": f"Bearer {HF_TOKEN}",
138
  "Accept": "application/json",
139
  "Content-Type": "application/json",
140
  }
141
- prompt = SYSTEM_PROMPT # baked in system; also send as user
142
- tried = []
 
 
 
143
  async with httpx.AsyncClient(timeout=90) as client:
144
- for mid in VLLM_MODELS:
145
- if not mid: continue
 
 
146
  tried.append(mid)
147
- url = _hf_url(mid)
148
- for body in _payloads_for_model(b64, SYSTEM_PROMPT):
149
- try:
150
- r = await client.post(url, headers=headers, json=body)
151
- if r.status_code != 200:
152
- continue
153
- data = r.json()
154
- # try typical response shapes:
155
- if isinstance(data, list):
156
- # LLaVA often returns [{"generated_text":"..."}]
157
- if data and isinstance(data[0], dict) and "generated_text" in data[0]:
158
- dish, ings = _parse_llm_json(str(data[0]["generated_text"]))
159
- else:
160
- dish, ings = _parse_llm_json(json.dumps(data))
161
- elif isinstance(data, dict):
162
- txt = ""
163
- if "generated_text" in data: txt = str(data["generated_text"])
164
- elif "choices" in data: txt = json.dumps(data["choices"])
165
- else: txt = json.dumps(data)
166
- dish, ings = _parse_llm_json(txt)
167
- else:
168
- dish, ings = _parse_llm_json(str(data))
169
- if dish or ings:
170
- return dish, ings, mid
171
- except Exception:
172
  continue
173
- # failed:
174
- return "", [], ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
 
176
  # =========================
177
- # Open Food Facts
178
  # =========================
179
  async def off_search_first(query: str) -> Optional[Dict[str, Any]]:
180
- if not query: return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
  try:
182
- params = {
183
- "search_terms": query,
184
- "search_simple": "1",
185
- "action": "process",
186
- "json": "1",
187
- "page_size": "5",
188
- "lc": "bg",
189
- "fields": ",".join([
190
- "product_name","product_name_bg","generic_name","generic_name_bg","brands",
191
- "nutriments","image_front_url","url"
192
- ])
193
- }
194
- async with httpx.AsyncClient(timeout=25) as client:
195
- r = await client.get(f"{OFF_BASE}/cgi/search.pl", params=params)
196
- if r.status_code != 200:
197
- return None
198
- js = r.json()
199
- prods = js.get("products") or []
200
- return prods[0] if prods else None
201
  except Exception:
202
- return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
 
204
- def off_per100(prod: Optional[Dict[str, Any]]) -> Dict[str,float]:
205
- n = (prod or {}).get("nutriments") or {}
206
- p = _num(n.get("proteins_100g"))
207
- c = _num(n.get("carbohydrates_100g"))
208
- f = _num(n.get("fat_100g"))
209
- k = _num(n.get("energy-kcal_100g"))
210
- if not k and (p or c or f): k = _kcal_from_macros_per100(p,c,f)
211
- return {"kcal100":k,"protein100":p,"carbs100":c,"fat100":f}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
 
213
  # =========================
214
- # Rows & totals
215
  # =========================
216
- async def rows_from_names(names: List[str], default_grams: float) -> pd.DataFrame:
217
- rows: List[List[Any]] = []
218
  for name in names:
219
  prod = await off_search_first(name)
220
- per100 = off_per100(prod)
221
- scaled = _scale_per100_to_grams(per100, float(default_grams))
222
- show = EN_BG.get(name, name)
223
- rows.append([show, float(default_grams), scaled["ккал"], scaled["белтъчини"], scaled["въглехидрати"], scaled["мазнини"]])
224
- return pd.DataFrame(rows, columns=EMPTY_DF.columns) if rows else EMPTY_DF.copy()
225
-
226
- def totals(df: pd.DataFrame) -> Tuple[float,float,float,float]:
227
- if df is None or df.empty: return (0.0,0.0,0.0,0.0)
228
- return (
229
- float(_num(df["Ккал"].sum())),
230
- float(_num(df["Белтъчини (g)"].sum())),
231
- float(_num(df["Въглехидрати (g)"].sum())),
232
- float(_num(df["Мазнини (g)"].sum())),
233
- )
234
 
235
  # =========================
236
- # Main callback
237
  # =========================
238
- async def analyze(
239
- image: Image.Image,
240
- grams_default: int,
241
- manual_df: pd.DataFrame
242
- ):
243
  """
244
- 1) Vision LLM → dish + ingredients (JSON).
245
- 2) OFF нутриенти за всеки.
246
- 3) Обединява с ръчната таблица (ако има валидни редове).
 
 
247
  """
248
- messages: List[str] = []
249
- auto_df = EMPTY_DF.copy()
250
-
251
- if image is not None:
252
- dish, ings, used = await vllm_extract(image)
253
- if used: messages.append(f"Vision LLM: {used}")
254
- if dish: messages.append(f"Ястие (LLM): {dish}")
255
- if ings:
256
- auto_df = await rows_from_names(ings[:12], float(grams_default))
257
- messages.append("Съставки (LLM): " + ", ".join(ings[:8]) + ("..." if len(ings)>8 else ""))
258
- else:
259
- messages.append("LLM не върна разпознаваеми съставки — ползвай ръчните редове отдолу.")
260
-
261
- # Manual rows
262
- manual_rows: List[List[Any]] = []
263
  try:
264
- if manual_df is not None and not manual_df.empty:
265
- # очакваме колони ["Съставка","Грамаж (g)"] или пълната форма; нормализирай
266
- cols = [c.lower() for c in manual_df.columns]
267
- if "съставка" in cols and "грамаж (g)" in cols:
268
- name_col = manual_df.columns[cols.index("съставка")]
269
- g_col = manual_df.columns[cols.index("грамаж (g)")]
270
- for _, row in manual_df.iterrows():
271
- name = str(row.get(name_col) or "").strip()
272
- grams = _num(row.get(g_col))
273
- if name and grams>0:
274
- prod = await off_search_first(name)
275
- per100 = off_per100(prod)
276
- scaled = _scale_per100_to_grams(per100, grams)
277
- manual_rows.append([name, grams, scaled["ккал"], scaled["белтъчини"], scaled["въглехидрати"], scaled["мазнини"]])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
278
  else:
279
- # ако потребителят вече е добавял пълни колони — просто ги вземи
280
- expected = ["Съставка","Грамаж (g)","Ккал","Белтъчини (g)","Въглехидрати (g)","Мазнини (g)"]
281
- ok = all(c in manual_df.columns for c in expected)
282
- manual_rows = manual_df[expected].values.tolist() if ok else []
 
 
 
 
 
 
 
 
 
 
 
283
  except Exception:
284
- messages.append("⚠️ Някои ръчни редове бяха невалидни и са пропуснати.")
 
285
 
286
- manual_out = pd.DataFrame(manual_rows, columns=EMPTY_DF.columns) if manual_rows else EMPTY_DF.copy()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
287
 
288
- out_df = pd.concat([auto_df, manual_out], ignore_index=True) if not auto_df.empty or not manual_out.empty else EMPTY_DF.copy()
289
- K,P,C,F = totals(out_df)
290
- return out_df, K, P, C, F, ("\n".join(messages) if messages else "Готово.")
 
 
 
 
 
 
 
 
 
 
 
291
 
292
  # =========================
293
  # UI (BG)
294
  # =========================
295
- with gr.Blocks(title="Калории от снимка Vision LLM + OFF") as demo:
296
  gr.Markdown(
297
- "## 📸 Калории от снимка (Vision LLM) или ръчни съставки\n"
298
- "• Използваме Vision LLM (LLaVA/Qwen2-VL) за **име на ястие + съставки** (EN), после смятаме нутриенти от **Open Food Facts**.\n"
299
- "• Ако моделът е недостъпен/ограничен, просто ползвай таблицата отдолу UI не се чупи."
300
  )
301
 
302
  with gr.Row():
303
  with gr.Column():
304
  img = gr.Image(type="pil", label="Снимка", height=320)
305
- grams_default = gr.Slider(10, 300, value=100, step=10, label="Начален грамаж за автоматичните съставки (g)")
 
 
 
306
  with gr.Column():
307
- info = gr.Textbox(label="Съобщения", lines=8)
308
-
309
- gr.Markdown("### 🧾 Ръчни съставки (редактируема таблица)")
310
- manual = gr.Dataframe(
311
- headers=["Съставка","Грамаж (g)"],
312
- datatype=["str","number"],
313
- value=[["",0]],
314
- row_count=(1,"dynamic"),
315
- label="Добавяй редове тук (например: chicken breast, 150)"
 
 
316
  )
317
 
318
- analyze_btn = gr.Button("🔍 Анализирай", variant="primary")
 
 
 
 
 
 
 
319
 
320
- out_df = gr.Dataframe(label="Детайли по съставки", interactive=True, wrap=True)
321
- total_kcal = gr.Number(label="Общо ккал", value=0.0, precision=1)
322
- total_p = gr.Number(label="Общо белтъчини (g)", value=0.0, precision=1)
323
- total_c = gr.Number(label="Общо въглехидрати (g)", value=0.0, precision=1)
324
- total_f = gr.Number(label="Общо мазнини (g)", value=0.0, precision=1)
 
 
 
325
 
 
326
  analyze_btn.click(
327
- analyze,
328
- inputs=[img, grams_default, manual],
329
- outputs=[out_df, total_kcal, total_p, total_c, total_f, info]
330
  )
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  demo.queue()
333
 
334
  if __name__ == "__main__":
 
1
+ import os, io, math, re, base64, traceback
2
+ from typing import List, Dict, Any, Tuple, Optional
 
3
 
4
  import httpx
 
5
  import pandas as pd
6
+ import gradio as gr
7
  from PIL import Image
8
 
9
  # =========================
10
+ # Конфиг (HF + OFF)
11
  # =========================
12
+ HF_TOKEN = os.getenv("HF_TOKEN") # Space → Settings → Repository secrets (scope: Read)
13
+ ENV_HF_MODEL = (os.getenv("HF_MODEL") or "").strip()
14
 
15
+ # Zero-shot multi-label (ако има достъп; иначе падаме към fallback)
16
+ ZSL_CHAIN = [
17
+ os.getenv("HF_ZSL_MODEL", "").strip() or "openai/clip-vit-base-patch32",
18
+ "openai/clip-vit-large-patch14",
19
+ "laion/CLIP-ViT-B-32-laion2B-s34B-b79K",
20
  ]
21
 
22
+ # Single-label за разпознаване на ястието
23
+ DISH_CHAIN = [
24
+ ENV_HF_MODEL or "nateraw/food101",
25
+ "microsoft/resnet-50",
26
+ "google/vit-base-patch16-224",
27
+ ]
 
 
 
 
 
 
 
 
 
 
28
 
29
+ OFF_BASE = "https://world.openfoodfacts.org"
30
 
31
+ # Кандидат-съставки за zero-shot
32
+ CANDIDATE_INGREDIENTS = [
33
+ # протеини
34
+ "chicken breast","chicken thigh","beef","steak","pork","pork belly","turkey","lamb",
35
+ "salami","bacon","ham","sausage","prosciutto",
36
+ "salmon","tuna","shrimp","prawn","white fish","sardines",
37
+ "egg","egg yolk","egg white",
38
+ "tofu","tempeh",
39
+ # млечни
40
+ "mozzarella","cheddar","parmesan","feta","goat cheese","yogurt","milk","butter","cream","sour cream",
41
+ # зърнени/нишестени
42
+ "rice","white rice","brown rice","pasta","spaghetti","noodles","tortilla","bread","bun","potato","sweet potato","couscous","quinoa",
43
+ # зеленчуци
44
+ "tomato","onion","red onion","garlic","bell pepper","cucumber","lettuce","spinach","mushroom","broccoli","carrot","corn","peas","eggplant","zucchini","cabbage",
45
+ # масла/сосове
46
+ "olive oil","sunflower oil","mayonnaise","ketchup","mustard","tomato sauce","pesto","soy sauce",
47
+ # други
48
+ "sugar","salt","black pepper","beans","lentils","chickpeas","olives",
49
+ # български популярни
50
+ "sirene","kashkaval","lyutenitsa","kyufte","kebapche","banitsa","tarator","shopska salad"
51
+ ]
 
 
 
 
52
 
53
+ # Рецептни правила (dish → ingredients), EN/BG ключови думи
54
+ DISH_RULES: Dict[str, List[str]] = {
55
+ # Пица
56
+ "pizza": ["mozzarella","tomato sauce","olive oil","basil","flour","yeast","salt"],
57
+ "margherita": ["mozzarella","tomato sauce","olive oil","basil","flour","yeast","salt"],
58
+ "пица": ["mozzarella","tomato sauce","olive oil","basil","flour","yeast","salt"],
59
+ # Паста
60
+ "pasta": ["pasta","olive oil","parmesan","tomato sauce","garlic","salt"],
61
+ "spaghetti": ["spaghetti","olive oil","parmesan","tomato sauce","garlic","salt"],
62
+ "макарони": ["pasta","olive oil","parmesan","tomato sauce","garlic","salt"],
63
+ # Бургер/сандвич
64
+ "burger": ["bun","beef","cheddar","lettuce","tomato","onion","mayonnaise","ketchup"],
65
+ "бургер": ["bun","beef","cheddar","lettuce","tomato","onion","mayonnaise","ketchup"],
66
+ "sandwich": ["bread","ham","cheddar","tomato","lettuce","butter"],
67
+ "сандвич": ["bread","ham","cheddar","tomato","lettuce","butter"],
68
+ # Салати
69
+ "salad": ["lettuce","tomato","cucumber","onion","olive oil","salt"],
70
+ "shopska": ["tomato","cucumber","onion","sirene","olive oil","salt"],
71
+ "шопска": ["tomato","cucumber","onion","sirene","olive oil","salt"],
72
+ # Месо
73
+ "steak": ["beef","olive oil","salt","black pepper"],
74
+ "chicken": ["chicken breast","olive oil","salt","black pepper"],
75
+ "пържола": ["beef","olive oil","salt","black pepper"],
76
+ "пилешко": ["chicken breast","olive oil","salt","black pepper"],
77
+ # BG класики
78
+ "кифтета": ["kyufte","onion","salt","black pepper","sunflower oil","bread"],
79
+ "кюфте": ["kyufte","onion","salt","black pepper","sunflower oil","bread"],
80
+ "кебапче": ["kebapche","salt","black pepper","sunflower oil","bread"],
81
+ "баница": ["flour","sirene","eggs","yogurt","sunflower oil","butter"],
82
+ "тарaтор": ["yogurt","cucumber","garlic","dill","walnuts","salt"],
83
+ "tarator": ["yogurt","cucumber","garlic","dill","walnuts","salt"],
84
+ }
85
+
86
+ EMPTY_TABLE = pd.DataFrame(columns=[
87
+ "Съставка","Грамаж (g)","ккал/100g","Белтъчини/100g","Въглехидрати/100g","Мазнини/100g","ккал"
88
+ ])
89
 
90
+ def hf_url(mid: str) -> str:
91
+ return f"https://api-inference.huggingface.co/models/{mid}"
92
 
93
  # =========================
94
+ # HF helpers
95
  # =========================
96
+ def _img_to_data_url(img: Image.Image) -> str:
97
+ buf = io.BytesIO()
98
+ img.save(buf, format="PNG")
99
+ b64 = base64.b64encode(buf.getvalue()).decode("ascii")
100
+ return f"data:image/png;base64,{b64}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
 
102
+ async def zeroshot_multilabel(img: Image.Image, labels: List[str], score_thresh: float = 0.12, top_k: int = 8
103
+ ) -> Tuple[List[str], Optional[str], Optional[str]]:
104
+ """Zero-shot multi-label (ако моделите са налични)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  if not HF_TOKEN:
106
+ return [], None, "Липсва HF_TOKEN."
107
+ payload_img = _img_to_data_url(img)
108
  headers = {
109
  "Authorization": f"Bearer {HF_TOKEN}",
110
  "Accept": "application/json",
111
  "Content-Type": "application/json",
112
  }
113
+ body = {
114
+ "inputs": {"image": payload_img, "candidate_labels": labels},
115
+ "parameters": {"multi_label": True}
116
+ }
117
+ tried: List[str] = []
118
  async with httpx.AsyncClient(timeout=90) as client:
119
+ for mid in ZSL_CHAIN:
120
+ if not mid:
121
+ continue
122
+ url = hf_url(mid)
123
  tried.append(mid)
124
+ try:
125
+ r = await client.post(url, headers=headers, json=body)
126
+ if r.status_code != 200:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
127
  continue
128
+ data = r.json()
129
+ cand_scores: List[Tuple[str, float]] = []
130
+ if isinstance(data, dict) and "labels" in data and "scores" in data:
131
+ for lab, sc in zip(data.get("labels") or [], data.get("scores") or []):
132
+ try:
133
+ cand_scores.append((str(lab), float(sc)))
134
+ except Exception:
135
+ pass
136
+ elif isinstance(data, list):
137
+ for it in data:
138
+ try:
139
+ cand_scores.append((str(it.get("label")), float(it.get("score") or 0)))
140
+ except Exception:
141
+ pass
142
+ cand_scores = [(l, s) for (l, s) in cand_scores if l]
143
+ cand_scores.sort(key=lambda x: x[1], reverse=True)
144
+ picked = [l for (l, s) in cand_scores if s >= score_thresh][:top_k]
145
+ if not picked and cand_scores:
146
+ picked = [l for (l, _) in cand_scores[:3]]
147
+ return picked, mid, None
148
+ except Exception:
149
+ continue
150
+ warn = f"Zero-shot неуспешно. Пробвани: {', '.join(tried)}."
151
+ return [], None, warn
152
+
153
+ async def classify_dish(img: Image.Image, k: int = 5) -> Tuple[List[str], Optional[str], Optional[str]]:
154
+ """Single-label класификация (за етикет на ястие) — връща топ етикети (dish candidates)."""
155
+ if not HF_TOKEN:
156
+ return [], None, "Липсва HF_TOKEN."
157
+ buf = io.BytesIO()
158
+ img.save(buf, format="PNG")
159
+ body = buf.getvalue()
160
+ headers = {
161
+ "Authorization": f"Bearer {HF_TOKEN}",
162
+ "Accept": "application/json",
163
+ "X-Wait-For-Model": "true",
164
+ }
165
+ tried: List[str] = []
166
+ async with httpx.AsyncClient(timeout=75) as client:
167
+ for mid in DISH_CHAIN:
168
+ if not mid:
169
+ continue
170
+ tried.append(mid)
171
+ try:
172
+ r = await client.post(hf_url(mid), headers=headers, content=body)
173
+ if r.status_code != 200:
174
+ continue
175
+ data = r.json()
176
+ preds = data if isinstance(data, list) else data[0]
177
+ labs: List[str] = []
178
+ for p in (preds or [])[:k]:
179
+ lab = str(p.get("label") or p.get("class") or p.get("className") or "").strip()
180
+ if lab:
181
+ labs.append(lab)
182
+ if labs:
183
+ return labs, mid, None
184
+ except Exception:
185
+ continue
186
+ warn = f"Dish класификация неуспешна. Пробвани: {', '.join(tried)}."
187
+ return [], None, warn
188
 
189
  # =========================
190
+ # OFF helpers
191
  # =========================
192
  async def off_search_first(query: str) -> Optional[Dict[str, Any]]:
193
+ if not query:
194
+ return None
195
+ params = {
196
+ "search_terms": query,
197
+ "search_simple": "1",
198
+ "action": "process",
199
+ "json": "1",
200
+ "page_size": "5",
201
+ "lc": "bg",
202
+ "fields": ",".join([
203
+ "product_name","product_name_bg","generic_name","generic_name_bg","brands",
204
+ "nutriments","image_front_url"
205
+ ])
206
+ }
207
+ async with httpx.AsyncClient(timeout=25) as client:
208
+ r = await client.get(f"{OFF_BASE}/cgi/search.pl", params=params)
209
+ if r.status_code != 200:
210
+ return None
211
+ js = r.json()
212
+ prods = js.get("products") or []
213
+ return prods[0] if prods else None
214
+
215
+ async def off_suggest(term: str) -> List[str]:
216
+ if not term:
217
+ return []
218
+ params = { "search_terms": term, "json": "1" }
219
+ async with httpx.AsyncClient(timeout=10) as client:
220
+ r = await client.get(f"{OFF_BASE}/cgi/suggest.pl", params=params)
221
+ if r.status_code != 200:
222
+ return []
223
  try:
224
+ data = r.json()
225
+ if isinstance(data, list):
226
+ return [str(x) for x in data][:20]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  except Exception:
228
+ pass
229
+ return []
230
+
231
+ def extract_kcal_macros_100(nutriments: Optional[Dict[str, Any]]) -> Dict[str, float]:
232
+ n = nutriments or {}
233
+ def num(v):
234
+ try:
235
+ x = float(v)
236
+ return x if math.isfinite(x) else 0.0
237
+ except Exception:
238
+ return 0.0
239
+ p = num(n.get("proteins_100g"))
240
+ c = num(n.get("carbohydrates_100g"))
241
+ f = num(n.get("fat_100g"))
242
+ kcal = num(n.get("energy-kcal_100g"))
243
+ if not kcal and (p or c or f):
244
+ kcal = p*4 + c*4 + f*9
245
+ return {"kcal100": round(kcal,1) if kcal else 0.0,
246
+ "p100": round(p,1) if p else 0.0,
247
+ "c100": round(c,1) if c else 0.0,
248
+ "f100": round(f,1) if f else 0.0}
249
+
250
+ # =========================
251
+ # Калкулации
252
+ # =========================
253
+ EMPTY_TABLE = pd.DataFrame(columns=[
254
+ "Съставка","Грамаж (g)","ккал/100g","Белтъчини/100g","Въглехидрати/100g","Мазнини/100g","ккал"
255
+ ])
256
+
257
+ def row_kcal(grams: float, kcal100: float, p100: float, c100: float, f100: float) -> float:
258
+ base = kcal100 if kcal100 else (p100*4 + c100*4 + f100*9)
259
+ return round((grams/100.0) * base, 1) if base else 0.0
260
+
261
+ def recompute_df(df: pd.DataFrame) -> Tuple[pd.DataFrame, Dict[str, float]]:
262
+ cols = ["Съставка","Грамаж (g)","ккал/100g","Белтъчини/100g","Въглехидрати/100g","Мазнини/100g","ккал"]
263
+ if df is None or df.empty:
264
+ return EMPTY_TABLE.copy(), {"sum_kcal":0.0,"sum_p":0.0,"sum_c":0.0,"sum_f":0.0}
265
+ for c in cols:
266
+ if c not in df.columns:
267
+ df[c] = 0.0 if c != "Съставка" else ""
268
+ df = df[cols].copy()
269
+ for c in ["Грамаж (g)","ккал/100g","Белтъчини/100g","Въглехидрати/100g","Мазнини/100g"]:
270
+ df[c] = pd.to_numeric(df[c], errors="coerce").fillna(0.0)
271
+ df["ккал"] = [
272
+ row_kcal(g, k, p, c, f)
273
+ for g, k, p, c, f in zip(
274
+ df["Грамаж (g)"], df["ккал/100g"], df["Белтъчини/100g"], df["Въглехидрати/100g"], df["Мазнини/100g"]
275
+ )
276
+ ]
277
+ factor = (df["Грамаж (g)"] / 100.0).astype(float)
278
+ sum_p = float((factor * df["Белтъчини/100g"]).sum().round(1))
279
+ sum_c = float((factor * df["Въглехидрати/100g"]).sum().round(1))
280
+ sum_f = float((factor * df["Мазнини/100g"]).sum().round(1))
281
+ sum_kcal = float(df["ккал"].sum().round(1))
282
+ return df, {"sum_kcal":sum_kcal,"sum_p":sum_p,"sum_c":sum_c,"sum_f":sum_f}
283
 
284
+ # =========================
285
+ # Генериране на съставки от ястие (fallback логика)
286
+ # =========================
287
+ def tokenize(s: str) -> List[str]:
288
+ s = (s or "").lower()
289
+ s = re.sub(r"[^a-zA-Zа-яА-Я0-9\s\-]", " ", s)
290
+ toks = re.split(r"\s+", s)
291
+ return [t for t in toks if t]
292
+
293
+ def infer_ingredients_from_dish(dish_label: str, alt_labels: List[str]) -> List[str]:
294
+ # 1) Рецептни правила по ключова дума
295
+ dish_text = " ".join([dish_label] + alt_labels).lower()
296
+ for key, ings in DISH_RULES.items():
297
+ if key in dish_text:
298
+ return list(dict.fromkeys(ings))[:8] # уникални, до 8 бр
299
+
300
+ # 2) Токенизация на етикетите → отсекаме очевидни съставки
301
+ stops = set(["with","and","of","the","a","an","на","с","от","и"])
302
+ toks = [t for t in tokenize(dish_text) if t not in stops]
303
+ # mapping някои токени към по-точни съставки
304
+ map_tok = {
305
+ "mozzarella":"mozzarella","parmesan":"parmesan","feta":"feta","sirene":"sirene","kashkaval":"kashkaval",
306
+ "chicken":"chicken breast","beef":"beef","pork":"pork","turkey":"turkey","lamb":"lamb",
307
+ "egg":"egg","eggs":"egg","tomato":"tomato","onion":"onion","garlic":"garlic","mushroom":"mushroom",
308
+ "pepper":"bell pepper","bread":"bread","bun":"bun","pasta":"pasta","spaghetti":"spaghetti",
309
+ "rice":"rice","noodles":"noodles","salmon":"salmon","tuna":"tuna","shrimp":"shrimp","prawn":"prawn",
310
+ "olive":"olive oil","oil":"olive oil","ketchup":"ketchup","mayonnaise":"mayonnaise","mustard":"mustard",
311
+ "sauce":"tomato sauce","lyutenitsa":"lyutenitsa"
312
+ }
313
+ found: List[str] = []
314
+ for t in toks:
315
+ if t in map_tok:
316
+ found.append(map_tok[t])
317
+ elif t in CANDIDATE_INGREDIENTS:
318
+ found.append(t)
319
+ elif t in ("pizza","пица","burger","бургер","sandwich","сандвич","pasta","спагети","spaghetti","salad","салата"):
320
+ continue
321
+ if found:
322
+ # докомплектовай с базови
323
+ if any(x in dish_text for x in ["pizza","пица"]):
324
+ found += ["flour","yeast","salt","olive oil","tomato sauce","mozzarella"]
325
+ if any(x in dish_text for x in ["pasta","spaghetti","макарони","спагети"]):
326
+ found += ["pasta","olive oil","parmesan"]
327
+ return list(dict.fromkeys(found))[:8]
328
+
329
+ # 3) Накрая — fallback: върни самите етикети като „съставки“
330
+ base = [dish_label] + alt_labels
331
+ # изчисти очевидни шумове
332
+ cleaned = [re.sub(r"[^a-zA-Zа-яА-Я0-9\s\-]", "", x).strip().lower() for x in base]
333
+ cleaned = [x for x in cleaned if x]
334
+ return list(dict.fromkeys(cleaned))[:5]
335
 
336
  # =========================
337
+ # Таблични редове от OFF
338
  # =========================
339
+ async def rows_from_names(names: List[str], default_grams: float = 100.0) -> pd.DataFrame:
340
+ rows = []
341
  for name in names:
342
  prod = await off_search_first(name)
343
+ vals = extract_kcal_macros_100((prod or {}).get("nutriments"))
344
+ rows.append([name, default_grams, vals["kcal100"], vals["p100"], vals["c100"], vals["f100"], 0.0])
345
+ df = pd.DataFrame(rows, columns=EMPTY_TABLE.columns)
346
+ df, _ = recompute_df(df)
347
+ return df
 
 
 
 
 
 
 
 
 
348
 
349
  # =========================
350
+ # Gradio callbacks
351
  # =========================
352
+ async def analyze_photo(image: Image.Image, grams_default: int, zsl_thresh: float, zsl_topk: int):
 
 
 
 
353
  """
354
+ Стъпки:
355
+ 1) Разпознай ястието (top-1 + алтернативи).
356
+ 2) Опитай zero-shot multi-label върху речник от съставки (ако налично).
357
+ 3) Ако няма — извлечи съставки по правила/токени/alt labels.
358
+ 4) OFF за всяка съставка → таблица + тотали.
359
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  try:
361
+ if image is None:
362
+ return ачи снимка.", EMPTY_TABLE.copy(), 0.0, 0.0, 0.0, 0.0
363
+
364
+ info: List[str] = []
365
+
366
+ # 1) dish labels
367
+ dish_labels, used_dish_model, warn_dish = await classify_dish(image, k=5)
368
+ if used_dish_model:
369
+ info.append(f"Dish модел: {used_dish_model}")
370
+ if warn_dish:
371
+ info.append(warn_dish)
372
+ dish_main = dish_labels[0] if dish_labels else ""
373
+
374
+ # 2) zero-shot multi-label ingredients
375
+ picked, used_zsl, warn_zsl = await zeroshot_multilabel(
376
+ image, CANDIDATE_INGREDIENTS, score_thresh=float(zsl_thresh), top_k=int(zsl_topk)
377
+ )
378
+ if used_zsl:
379
+ info.append(f"Zero-shot модел: {used_zsl}")
380
+ if warn_zsl:
381
+ info.append(warn_zsl)
382
+
383
+ # 3) ако няма zero-shot резултати → derive от dish + alt labels
384
+ if not picked:
385
+ picked = infer_ingredients_from_dish(dish_main, dish_labels[1:])
386
+
387
+ if not picked:
388
+ msg = ("Няма разпознати съставки. "
389
+ "Можеш да добавиш ръчно от полето по-долу и да попълниш стойности.")
390
+ info.insert(0, f"Ястие: {dish_main or '—'}")
391
+ return "\n".join(info + [msg]), EMPTY_TABLE.copy(), 0.0, 0.0, 0.0, 0.0
392
+
393
+ # 4) OFF → таблица
394
+ df = await rows_from_names(picked, float(grams_default))
395
+ df, totals = recompute_df(df)
396
+
397
+ info.insert(0, f"Ястие: {dish_main or '—'}")
398
+ if picked:
399
+ info.append(f"Съставки: {', '.join(picked)}")
400
+
401
+ return "\n".join(info), df, totals["sum_kcal"], totals["sum_p"], totals["sum_c"], totals["sum_f"]
402
+ except Exception as e:
403
+ tb = traceback.format_exc(limit=2)
404
+ return f"Грешка при анализ: {e}\n{tb}", EMPTY_TABLE.copy(), 0.0, 0.0, 0.0, 0.0
405
+
406
+ async def apply_names(text_area: str, df: pd.DataFrame, grams_default: int):
407
+ """
408
+ Бързо добавяне: редове 'име[, грамаж]'.
409
+ """
410
+ try:
411
+ names: List[str] = []
412
+ grams_map: Dict[str, float] = {}
413
+ for raw in (text_area or "").splitlines():
414
+ line = raw.strip()
415
+ if not line:
416
+ continue
417
+ if "," in line:
418
+ name, gr = line.split(",", 1)
419
+ name = name.strip()
420
+ try:
421
+ grams_map[name] = float(gr.strip())
422
+ except Exception:
423
+ grams_map[name] = float(grams_default)
424
+ names.append(name)
425
  else:
426
+ names.append(line)
427
+
428
+ if not names:
429
+ df2, totals2 = recompute_df(df or EMPTY_TABLE.copy())
430
+ return df2, totals2["sum_kcal"], totals2["sum_p"], totals2["sum_c"], totals2["sum_f"]
431
+
432
+ add_df = await rows_from_names(names, float(grams_default))
433
+ for i, row in add_df.iterrows():
434
+ n = row["Съставка"]
435
+ if n in grams_map:
436
+ add_df.loc[i, "Грамаж (g)"] = grams_map[n]
437
+
438
+ merged = add_df if (df is None or df.empty) else pd.concat([df, add_df], ignore_index=True)
439
+ merged, totals = recompute_df(merged)
440
+ return merged, totals["sum_kcal"], totals["sum_p"], totals["sum_c"], totals["sum_f"]
441
  except Exception:
442
+ df2, totals2 = recompute_df(EMPTY_TABLE.copy())
443
+ return df2, totals2["sum_kcal"], totals2["sum_p"], totals2["sum_c"], totals2["sum_f"]
444
 
445
+ async def refresh_nutrients(df: pd.DataFrame):
446
+ """
447
+ Попълва ккал/100g и макроси/100g от OFF за редове, където липсват (0),
448
+ удобно след като промениш името на съставка.
449
+ """
450
+ try:
451
+ if df is None or df.empty:
452
+ return EMPTY_TABLE.copy(), 0.0, 0.0, 0.0, 0.0
453
+ df = df.copy()
454
+ for i, row in df.iterrows():
455
+ name = str(row.get("Съставка") or "").strip()
456
+ has_any = any([
457
+ float(row.get("ккал/100g") or 0),
458
+ float(row.get("Белтъчини/100g") or 0),
459
+ float(row.get("Въглехидрати/100g") or 0),
460
+ float(row.get("Мазнини/100g") or 0),
461
+ ])
462
+ if name and not has_any:
463
+ prod = await off_search_first(name)
464
+ vals = extract_kcal_macros_100((prod or {}).get("nutriments"))
465
+ df.loc[i, "ккал/100g"] = vals["kcal100"]
466
+ df.loc[i, "Белтъчини/100g"] = vals["p100"]
467
+ df.loc[i, "Въглехидрати/100g"] = vals["c100"]
468
+ df.loc[i, "Мазнини/100g"] = vals["f100"]
469
+ df2, totals = recompute_df(df)
470
+ return df2, totals["sum_kcal"], totals["sum_p"], totals["sum_c"], totals["sum_f"]
471
+ except Exception:
472
+ df2, totals2 = recompute_df(EMPTY_TABLE.copy())
473
+ return df2, totals2["sum_kcal"], totals2["sum_p"], totals2["sum_c"], totals2["sum_f"]
474
 
475
+ async def recalc(df: pd.DataFrame):
476
+ """Пресмята от текущата таблица след ръчни редакции."""
477
+ try:
478
+ df2, totals = recompute_df(df)
479
+ return df2, totals["sum_kcal"], totals["sum_p"], totals["sum_c"], totals["sum_f"]
480
+ except Exception:
481
+ df2, totals2 = recompute_df(EMPTY_TABLE.copy())
482
+ return df2, totals2["sum_kcal"], totals2["sum_p"], totals2["sum_c"], totals2["sum_f"]
483
+
484
+ async def suggest(term: str) -> List[str]:
485
+ try:
486
+ return await off_suggest(term)
487
+ except Exception:
488
+ return []
489
 
490
  # =========================
491
  # UI (BG)
492
  # =========================
493
+ with gr.Blocks(title="CalorieCam Ястие Съставки (OFF only)") as demo:
494
  gr.Markdown(
495
+ "## 📸 CalorieCam — Разпознаване на ястие и съставки (Open Food Facts)\n"
496
+ "• Първо разпознаваме ястието; после извличаме съставки (zero-shot ако е налично, иначе правила/етикети).\n"
497
+ "• Таблицата е изцяло редактирана; добавяй редове, попълвай липсващи стойности от OFF и пресмятай."
498
  )
499
 
500
  with gr.Row():
501
  with gr.Column():
502
  img = gr.Image(type="pil", label="Снимка", height=320)
503
+ grams_default = gr.Slider(10, 300, value=100, step=10, label="Начален грамаж за всяка съставка (g)")
504
+ zsl_thresh = gr.Slider(0.05, 0.4, value=0.12, step=0.01, label="Праг за zero-shot (ако е наличен)")
505
+ zsl_topk = gr.Slider(1, 12, value=8, step=1, label="Максимум съставки от снимка")
506
+ analyze_btn = gr.Button("🔍 Анализирай снимката", variant="primary")
507
  with gr.Column():
508
+ info = gr.Textbox(label="Резюме", lines=10)
509
+
510
+ gr.Markdown("### 🧾 Таблица със съставки (редактируема)")
511
+ df = gr.Dataframe(
512
+ headers=["Съставка","Грамаж (g)","ккал/100g","Белтъчини/100g","Въглехидрати/100g","Мазнини/100g","ккал"],
513
+ value=EMPTY_TABLE.copy(),
514
+ row_count=(1, "dynamic"),
515
+ datatype=["str","number","number","number","number","number","number"],
516
+ interactive=True,
517
+ wrap=True,
518
+ label="Добавяй/редактирай редове тук"
519
  )
520
 
521
+ gr.Markdown("#### ➕ Бързо добавяне (по един ред: `име[, грамаж]`)")
522
+ with gr.Row():
523
+ quick = gr.Textbox(label="Списък със съставки", placeholder="rice, 150\negg, 60\nolive oil, 10")
524
+ quick_btn = gr.Button("➕ Добави към таблицата")
525
+ with gr.Row():
526
+ add_term = gr.Textbox(label="Автодопълване (OFF)", placeholder="chicken breast / домати")
527
+ add_choices = gr.Dropdown(label="Предложения", choices=[], interactive=True)
528
+ add_apply = gr.Button("Добави избраната")
529
 
530
+ with gr.Row():
531
+ fill_btn = gr.Button("🧪 Попълни липсващи нутриенти от OFF (по името)")
532
+ recalc_btn = gr.Button("🧮 Пресметни калориите от таблицата")
533
+ with gr.Row():
534
+ total_kcal = gr.Number(label="Общо ккал", value=0.0, precision=1)
535
+ total_p = gr.Number(label="Общо белтъчини (g)", value=0.0, precision=1)
536
+ total_c = gr.Number(label="Общо въглехидрати (g)", value=0.0, precision=1)
537
+ total_f = gr.Number(label="Общо мазнини (g)", value=0.0, precision=1)
538
 
539
+ # Свързване
540
  analyze_btn.click(
541
+ analyze_photo,
542
+ inputs=[img, grams_default, zsl_thresh, zsl_topk],
543
+ outputs=[info, df, total_kcal, total_p, total_c, total_f]
544
  )
545
 
546
+ quick_btn.click(
547
+ apply_names,
548
+ inputs=[quick, df, grams_default],
549
+ outputs=[df, total_kcal, total_p, total_c, total_f]
550
+ )
551
+
552
+ add_term.change(suggest, inputs=[add_term], outputs=[add_choices])
553
+
554
+ def _add_choice(current_df: pd.DataFrame, term: str, choice: str, grams_default: int):
555
+ name = (choice or term or "").strip()
556
+ if not name:
557
+ return current_df or EMPTY_TABLE.copy()
558
+ if current_df is None or current_df.empty:
559
+ current_df = EMPTY_TABLE.copy()
560
+ new_row = pd.DataFrame([[name, float(grams_default), 0.0, 0.0, 0.0, 0.0, 0.0]], columns=current_df.columns)
561
+ merged = pd.concat([current_df, new_row], ignore_index=True)
562
+ merged, _ = recompute_df(merged)
563
+ return merged
564
+
565
+ add_apply.click(_add_choice, inputs=[df, add_term, add_choices, grams_default], outputs=[df])
566
+
567
+ fill_btn.click(refresh_nutrients, inputs=[df], outputs=[df, total_kcal, total_p, total_c, total_f])
568
+ recalc_btn.click(recalc, inputs=[df], outputs=[df, total_kcal, total_p, total_c, total_f])
569
+
570
  demo.queue()
571
 
572
  if __name__ == "__main__":