Gilgarmesh commited on
Commit
4c03db9
·
verified ·
1 Parent(s): 2b9a5f1

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1593 -0
app.py ADDED
@@ -0,0 +1,1593 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Streamlit entry point: streamlit run app.py — meal nutrition scan UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import shutil
7
+ import io
8
+ import os
9
+ import sys
10
+ import tempfile
11
+ import textwrap
12
+ from pathlib import Path
13
+ import base64
14
+ from typing import Any
15
+
16
+ import cv2
17
+ import pandas as pd
18
+ import streamlit as st
19
+ import yaml
20
+ from PIL import Image
21
+
22
+ PROJECT_ROOT = Path(__file__).resolve().parent
23
+ SCRIPT_DIR = PROJECT_ROOT / "scripts"
24
+ if str(SCRIPT_DIR) not in sys.path:
25
+ sys.path.insert(0, str(SCRIPT_DIR))
26
+
27
+ os.environ.setdefault("YOLO_CONFIG_DIR", str(PROJECT_ROOT / ".ultralytics"))
28
+ os.environ.setdefault("MPLCONFIGDIR", str(PROJECT_ROOT / ".matplotlib"))
29
+
30
+ from estimate_macros_from_segments import estimate_grams, estimate_macros # pyright: ignore[reportMissingImports]
31
+ from meal_macro_pipeline import ( # pyright: ignore[reportMissingImports]
32
+ DEFAULT_PORTIONS,
33
+ DEFAULT_WEIGHTS,
34
+ load_yolo_model,
35
+ run_meal_analysis,
36
+ )
37
+
38
+ ACCENT = "#007AFF"
39
+ CLASS_ORDER = ("meat", "rice", "vegetables")
40
+ PLATE_WEIGHT_MIN = 100
41
+ PLATE_WEIGHT_MAX = 1200
42
+ CLASS_GRAMS_MAX = 500
43
+
44
+ CLASS_META: dict[str, dict[str, str]] = {
45
+ "meat": {"icon": "🥩", "label": "Meat", "accent": "#FF3B30"},
46
+ "rice": {"icon": "🍚", "label": "Rice", "accent": "#FF9500"},
47
+ "vegetables": {"icon": "🥬", "label": "Vegetables", "accent": "#34C759"},
48
+ }
49
+
50
+ NUTRIENT_GOALS: dict[str, dict[str, float | str | bool]] = {
51
+ "kcal": {"label": "Calories", "goal": 700, "unit": "kcal", "icon": "⚡"},
52
+ "protein": {"label": "Protein", "goal": 35, "unit": "g", "icon": "💪"},
53
+ "fat": {"label": "Fat", "goal": 25, "unit": "g", "icon": "🫒", "inverse": True},
54
+ "carbs": {"label": "Carbs", "goal": 90, "unit": "g", "icon": "🌾", "inverse": True},
55
+ }
56
+
57
+ PORTION_PRESETS: dict[str, int | None] = {
58
+ "Small (300g)": 300,
59
+ "Medium (500g)": 500,
60
+ "Large (750g)": 750,
61
+ "Custom": None,
62
+ }
63
+
64
+ TOTAL_WIZARD_STEPS = 5
65
+ WIZARD_STEP_NAMES = ("Upload", "Plate", "Analyze", "Results", "Details")
66
+
67
+ THEME_CSS = f"""
68
+ @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap');
69
+
70
+ html, body, [class*="css"] {{
71
+ font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'DM Sans', sans-serif !important;
72
+ }}
73
+
74
+ .stApp {{
75
+ background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%) !important;
76
+ background-attachment: fixed !important;
77
+ }}
78
+
79
+ .block-container {{
80
+ padding-top: 1rem !important;
81
+ padding-bottom: 3rem !important;
82
+ max-width: 920px !important;
83
+ }}
84
+
85
+ /* Wizard */
86
+ .wizard-progress-label {{
87
+ text-align: center;
88
+ font-size: 0.88rem;
89
+ font-weight: 600;
90
+ color: #8E8E93;
91
+ margin: 0.35rem 0 1.75rem 0;
92
+ }}
93
+ .wizard-dots {{
94
+ display: flex;
95
+ justify-content: center;
96
+ gap: 0.65rem;
97
+ margin: 0.75rem 0 0.5rem;
98
+ }}
99
+ .wizard-dot {{
100
+ width: 11px;
101
+ height: 11px;
102
+ border-radius: 50%;
103
+ background: #D1D1D6;
104
+ transition: background 0.25s ease, transform 0.25s ease;
105
+ }}
106
+ .wizard-dot.active {{
107
+ background: {ACCENT};
108
+ transform: scale(1.15);
109
+ }}
110
+ .wizard-dot.done {{
111
+ background: #34C759;
112
+ }}
113
+ .wizard-page {{
114
+ min-height: 52vh;
115
+ padding: 0.5rem 0 2rem;
116
+ }}
117
+ .wizard-title {{
118
+ font-size: 2.15rem !important;
119
+ font-weight: 700 !important;
120
+ color: #1D1D1F !important;
121
+ text-align: center;
122
+ margin: 0 0 0.5rem 0 !important;
123
+ letter-spacing: -0.03em !important;
124
+ }}
125
+ .wizard-subtitle {{
126
+ text-align: center;
127
+ color: #636366;
128
+ font-size: 1.1rem;
129
+ margin: 0 0 2.25rem 0;
130
+ line-height: 1.45;
131
+ }}
132
+ .wizard-center {{
133
+ max-width: 560px;
134
+ margin: 0 auto;
135
+ }}
136
+ .wizard-upload-zone {{
137
+ text-align: center;
138
+ padding: 3rem 2rem;
139
+ border: 2px dashed rgba(0, 122, 255, 0.4);
140
+ border-radius: 24px;
141
+ background: rgba(255, 255, 255, 0.55);
142
+ margin-bottom: 1.5rem;
143
+ }}
144
+ .wizard-upload-zone h3 {{
145
+ font-size: 1.35rem;
146
+ font-weight: 700;
147
+ color: #1D1D1F;
148
+ margin: 0 0 0.5rem 0;
149
+ }}
150
+ .wizard-preview {{
151
+ text-align: center;
152
+ margin: 1.5rem auto 2rem;
153
+ max-width: 420px;
154
+ }}
155
+ .wizard-preview img {{
156
+ width: 100%;
157
+ max-height: 320px;
158
+ object-fit: cover;
159
+ border-radius: 20px;
160
+ box-shadow: 0 12px 40px rgba(0,0,0,0.1);
161
+ border: 1px solid rgba(0,0,0,0.06);
162
+ }}
163
+ .wizard-weight-display {{
164
+ text-align: center;
165
+ font-size: 3rem;
166
+ font-weight: 700;
167
+ color: {ACCENT};
168
+ margin: 0.5rem 0 0.25rem;
169
+ letter-spacing: -0.03em;
170
+ }}
171
+ .wizard-preset-row {{
172
+ display: flex;
173
+ gap: 0.75rem;
174
+ justify-content: center;
175
+ flex-wrap: wrap;
176
+ margin: 1.5rem 0 2rem;
177
+ }}
178
+ .analyze-hero {{
179
+ text-align: center;
180
+ padding: 3rem 1rem;
181
+ }}
182
+ @keyframes spin-ring {{
183
+ 0% {{ transform: rotate(0deg); }}
184
+ 100% {{ transform: rotate(360deg); }}
185
+ }}
186
+ .spinner-ring {{
187
+ width: 56px;
188
+ height: 56px;
189
+ border: 4px solid rgba(0, 122, 255, 0.15);
190
+ border-top-color: {ACCENT};
191
+ border-radius: 50%;
192
+ animation: spin-ring 0.9s linear infinite;
193
+ margin: 1.5rem auto;
194
+ }}
195
+
196
+ #MainMenu, footer, [data-testid="stToolbar"] {{
197
+ visibility: hidden;
198
+ }}
199
+ header[data-testid="stHeader"] {{
200
+ background: transparent !important;
201
+ }}
202
+ [data-testid="stSidebarCollapsedControl"] {{
203
+ visibility: visible !important;
204
+ }}
205
+
206
+ section[data-testid="stSidebar"] {{
207
+ background: rgba(255, 255, 255, 0.72) !important;
208
+ backdrop-filter: blur(20px) !important;
209
+ border-right: 1px solid rgba(0, 0, 0, 0.06) !important;
210
+ }}
211
+
212
+ .glass-card {{
213
+ background: rgba(255, 255, 255, 0.82);
214
+ border: 1px solid rgba(0, 0, 0, 0.06);
215
+ box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06);
216
+ border-radius: 18px;
217
+ padding: 1.5rem;
218
+ margin-bottom: 1.5rem;
219
+ }}
220
+
221
+ .step-block {{
222
+ margin-bottom: 2rem;
223
+ }}
224
+ .step-label {{
225
+ font-size: 0.8rem;
226
+ font-weight: 700;
227
+ text-transform: uppercase;
228
+ letter-spacing: 0.06em;
229
+ color: {ACCENT};
230
+ margin: 0 0 0.35rem 0;
231
+ }}
232
+ .step-title {{
233
+ font-size: 1.35rem;
234
+ font-weight: 700;
235
+ color: #1D1D1F;
236
+ margin: 0 0 0.35rem 0;
237
+ letter-spacing: -0.02em;
238
+ }}
239
+ .step-sub {{
240
+ font-size: 0.95rem;
241
+ color: #636366;
242
+ margin: 0 0 1rem 0;
243
+ }}
244
+
245
+ .section-header {{
246
+ font-size: 1.25rem;
247
+ font-weight: 700;
248
+ color: #1D1D1F;
249
+ margin: 0 0 1rem 0;
250
+ padding-left: 0.75rem;
251
+ border-left: 4px solid {ACCENT};
252
+ }}
253
+
254
+ .upload-empty {{
255
+ text-align: center;
256
+ padding: 1.75rem 1.25rem;
257
+ border: 2px dashed rgba(0, 122, 255, 0.35);
258
+ border-radius: 16px;
259
+ background: rgba(255, 255, 255, 0.5);
260
+ }}
261
+ .upload-empty h3 {{
262
+ font-size: 1.15rem;
263
+ font-weight: 700;
264
+ color: #1D1D1F;
265
+ margin: 0 0 0.35rem 0;
266
+ }}
267
+ .upload-empty p {{
268
+ color: #636366;
269
+ margin: 0.2rem 0;
270
+ font-size: 0.95rem;
271
+ }}
272
+ .upload-empty .hint {{
273
+ font-size: 0.82rem;
274
+ color: #8E8E93;
275
+ margin-top: 0.5rem;
276
+ }}
277
+
278
+ .upload-preview-row {{
279
+ display: flex;
280
+ gap: 1.25rem;
281
+ align-items: flex-start;
282
+ }}
283
+ .upload-thumb {{
284
+ width: 120px;
285
+ height: 120px;
286
+ object-fit: cover;
287
+ border-radius: 14px;
288
+ border: 1px solid rgba(0,0,0,0.08);
289
+ flex-shrink: 0;
290
+ }}
291
+ .upload-meta h4 {{
292
+ margin: 0 0 0.35rem 0;
293
+ font-size: 1.05rem;
294
+ color: #1D1D1F;
295
+ }}
296
+ .upload-meta p {{
297
+ margin: 0;
298
+ color: #636366;
299
+ font-size: 0.9rem;
300
+ }}
301
+
302
+ .alert-user {{
303
+ background: #FFF9E6;
304
+ border: 1px solid #F5D76E;
305
+ border-radius: 14px;
306
+ padding: 1.15rem 1.25rem;
307
+ margin: 1.25rem 0 1.75rem 0;
308
+ }}
309
+ .alert-user h4 {{
310
+ margin: 0 0 0.5rem 0;
311
+ font-size: 1.05rem;
312
+ color: #7A5C00;
313
+ }}
314
+ .alert-user p {{
315
+ margin: 0;
316
+ color: #5C4A00;
317
+ font-size: 0.95rem;
318
+ line-height: 1.45;
319
+ }}
320
+ .alert-error {{
321
+ background: #FFF0F0;
322
+ border-color: #FFB4B4;
323
+ }}
324
+ .alert-error h4 {{ color: #8B1A1A; }}
325
+ .alert-error p {{ color: #6B1515; }}
326
+
327
+ .status-row {{
328
+ display: flex;
329
+ flex-direction: column;
330
+ gap: 0.65rem;
331
+ font-size: 0.88rem;
332
+ }}
333
+ .status-item {{
334
+ display: flex;
335
+ align-items: center;
336
+ gap: 0.5rem;
337
+ color: #1D1D1F;
338
+ }}
339
+ .status-dot {{
340
+ width: 9px;
341
+ height: 9px;
342
+ border-radius: 50%;
343
+ flex-shrink: 0;
344
+ }}
345
+ .dot-green {{ background: #34C759; }}
346
+ .dot-amber {{ background: #FF9500; }}
347
+ .dot-red {{ background: #FF3B30; }}
348
+ .dot-gray {{ background: #AEAEB2; }}
349
+
350
+ .pill {{
351
+ display: inline-flex;
352
+ align-items: center;
353
+ padding: 0.45rem 0.9rem;
354
+ border-radius: 99px;
355
+ font-size: 0.88rem;
356
+ font-weight: 600;
357
+ margin-bottom: 1.25rem;
358
+ }}
359
+ .pill-ok {{ background: #E8F5E9; color: #248A3D; }}
360
+ .pill-info {{ background: #E8F0FE; color: #0051D5; }}
361
+ .pill-warn {{ background: #FFF9E6; color: #9A6B00; }}
362
+
363
+ .breakdown-summary {{
364
+ display: grid;
365
+ grid-template-columns: 1fr auto;
366
+ gap: 0.35rem 1rem;
367
+ font-size: 0.95rem;
368
+ margin-bottom: 1.25rem;
369
+ padding-bottom: 1rem;
370
+ border-bottom: 1px solid rgba(0,0,0,0.06);
371
+ }}
372
+ .breakdown-summary .label {{ color: #636366; }}
373
+ .breakdown-summary .val {{ font-weight: 700; color: #1D1D1F; text-align: right; }}
374
+ .breakdown-assigned {{
375
+ font-size: 0.88rem;
376
+ color: #8E8E93;
377
+ margin: -0.5rem 0 1rem 0;
378
+ }}
379
+
380
+ .score-block {{ text-align: center; padding: 0.25rem 0; }}
381
+ .score-heading {{
382
+ font-size: 0.8rem;
383
+ font-weight: 700;
384
+ text-transform: uppercase;
385
+ letter-spacing: 0.05em;
386
+ color: #8E8E93;
387
+ margin: 0 0 0.75rem 0;
388
+ }}
389
+ .score-pulse-wrap {{
390
+ position: relative;
391
+ width: 150px;
392
+ height: 150px;
393
+ margin: 0 auto 0.85rem;
394
+ display: flex;
395
+ align-items: center;
396
+ justify-content: center;
397
+ }}
398
+ .score-pulse-wrap::before {{
399
+ content: '';
400
+ position: absolute;
401
+ inset: -8px;
402
+ border-radius: 50%;
403
+ background: radial-gradient(circle, rgba(0, 122, 255, 0.3) 0%, transparent 70%);
404
+ animation: pulse-glow 2.2s ease-in-out infinite;
405
+ }}
406
+ @keyframes pulse-glow {{
407
+ 0%, 100% {{ opacity: 0.4; transform: scale(0.96); }}
408
+ 50% {{ opacity: 1; transform: scale(1.04); }}
409
+ }}
410
+ .score-ring {{
411
+ position: relative;
412
+ z-index: 1;
413
+ width: 150px;
414
+ height: 150px;
415
+ border-radius: 50%;
416
+ display: flex;
417
+ flex-direction: column;
418
+ align-items: center;
419
+ justify-content: center;
420
+ background: rgba(255,255,255,0.9);
421
+ border: 1px solid rgba(0,0,0,0.06);
422
+ box-shadow: 0 4px 20px rgba(0,0,0,0.06);
423
+ }}
424
+ .score-ring .num {{
425
+ font-size: 2.5rem;
426
+ font-weight: 700;
427
+ line-height: 1;
428
+ }}
429
+ .score-ring .denom {{
430
+ font-size: 0.95rem;
431
+ color: #8E8E93;
432
+ font-weight: 600;
433
+ }}
434
+ .score-label-below {{
435
+ font-size: 1.2rem;
436
+ font-weight: 700;
437
+ margin: 0 0 0.5rem 0;
438
+ }}
439
+ .score-good {{ color: #248A3D; }}
440
+ .score-mid {{ color: #B8860B; }}
441
+ .score-bad {{ color: #C41E3A; }}
442
+ .score-hint {{
443
+ font-size: 0.9rem;
444
+ color: #636366;
445
+ margin: 0 0 1rem 0;
446
+ line-height: 1.4;
447
+ }}
448
+ .factor-chips {{
449
+ display: flex;
450
+ flex-wrap: wrap;
451
+ gap: 0.4rem;
452
+ justify-content: center;
453
+ margin-bottom: 1rem;
454
+ }}
455
+ .factor-chip {{
456
+ font-size: 0.78rem;
457
+ font-weight: 600;
458
+ padding: 0.3rem 0.55rem;
459
+ border-radius: 8px;
460
+ background: rgba(0,0,0,0.05);
461
+ color: #1D1D1F;
462
+ }}
463
+ .chip-good {{ background: #E8F5E9; color: #248A3D; }}
464
+ .chip-mid {{ background: #FFF9E6; color: #9A6B00; }}
465
+ .chip-low {{ background: #FFF0F0; color: #C41E3A; }}
466
+
467
+ .nutrient-chips {{
468
+ display: flex;
469
+ flex-wrap: wrap;
470
+ gap: 0.5rem;
471
+ justify-content: center;
472
+ margin-top: 0.75rem;
473
+ }}
474
+ .n-chip {{
475
+ font-size: 0.8rem;
476
+ font-weight: 600;
477
+ padding: 0.35rem 0.65rem;
478
+ border-radius: 10px;
479
+ background: rgba(0, 122, 255, 0.1);
480
+ color: #0051D5;
481
+ }}
482
+
483
+ .nutrient-row {{ margin-bottom: 1.4rem; }}
484
+ .nutrient-head {{
485
+ display: flex;
486
+ justify-content: space-between;
487
+ align-items: flex-end;
488
+ margin-bottom: 0.5rem;
489
+ }}
490
+ .nutrient-head .name {{ font-size: 1rem; font-weight: 600; color: #1D1D1F; }}
491
+ .nutrient-head .vals {{ font-size: 1rem; font-weight: 700; color: #1D1D1F; }}
492
+ .nutrient-head .pct {{ font-size: 0.88rem; font-weight: 600; color: {ACCENT}; margin-left: 0.3rem; }}
493
+ .nutrient-goal {{ font-size: 0.8rem; color: #8E8E93; margin-top: 0.3rem; }}
494
+ .bar-track {{
495
+ height: 16px;
496
+ background: rgba(0, 0, 0, 0.06);
497
+ border-radius: 999px;
498
+ overflow: hidden;
499
+ }}
500
+ .bar-fill {{
501
+ height: 100%;
502
+ border-radius: 999px;
503
+ background: linear-gradient(90deg, {ACCENT}, #0051D5);
504
+ }}
505
+
506
+ .plate-item {{ margin-bottom: 1.5rem; }}
507
+ .plate-row-head {{
508
+ display: flex;
509
+ align-items: center;
510
+ justify-content: space-between;
511
+ gap: 0.75rem;
512
+ margin-bottom: 0.35rem;
513
+ }}
514
+ .plate-row-name {{
515
+ font-size: 1.05rem;
516
+ font-weight: 600;
517
+ color: #1D1D1F;
518
+ }}
519
+ .plate-not-detected {{
520
+ font-size: 0.88rem;
521
+ color: #8E8E93;
522
+ font-style: italic;
523
+ }}
524
+ .plate-bar-track {{
525
+ height: 10px;
526
+ background: rgba(0,0,0,0.06);
527
+ border-radius: 999px;
528
+ overflow: hidden;
529
+ margin-top: 0.5rem;
530
+ }}
531
+ .plate-bar-fill {{
532
+ height: 100%;
533
+ border-radius: 999px;
534
+ transition: width 0.35s ease;
535
+ background: linear-gradient(90deg, var(--accent), var(--accent-light));
536
+ }}
537
+
538
+ .empty-hint {{
539
+ text-align: center;
540
+ padding: 2rem 1.5rem;
541
+ color: #636366;
542
+ font-size: 1rem;
543
+ }}
544
+
545
+ [data-testid="stFileUploader"] {{
546
+ margin-top: -0.5rem;
547
+ }}
548
+ [data-testid="stFileUploader"] section {{
549
+ border: none !important;
550
+ background: transparent !important;
551
+ padding: 0 !important;
552
+ min-height: 0 !important;
553
+ }}
554
+ [data-testid="stFileUploader"] section > div {{
555
+ padding: 0 !important;
556
+ }}
557
+
558
+ div.stButton > button[kind="primary"] {{
559
+ background: linear-gradient(135deg, #007AFF, #0051D5) !important;
560
+ color: white !important;
561
+ border: none !important;
562
+ border-radius: 14px !important;
563
+ padding: 0.7rem 1.5rem !important;
564
+ font-weight: 600 !important;
565
+ font-size: 1rem !important;
566
+ }}
567
+ div.stButton > button:disabled {{
568
+ opacity: 0.55 !important;
569
+ }}
570
+
571
+ [data-testid="stImage"] img {{
572
+ border-radius: 16px !important;
573
+ }}
574
+ """
575
+
576
+ def ensure_weights() -> None:
577
+ """Download YOLO weights from the Hugging Face Hub if they're not already
578
+ at the path the pipeline expects. Uses DEFAULT_WEIGHTS, so the file lands
579
+ exactly where the existing check looks for it."""
580
+ if DEFAULT_WEIGHTS.exists():
581
+ return
582
+ repo_id = os.environ.get("WEIGHTS_REPO_ID")
583
+ if not repo_id:
584
+ return # no repo configured; the normal "not found" error will show
585
+ from huggingface_hub import hf_hub_download
586
+ downloaded = hf_hub_download(
587
+ repo_id=repo_id,
588
+ filename=os.environ.get("WEIGHTS_FILENAME", "best.pt"),
589
+ )
590
+ DEFAULT_WEIGHTS.parent.mkdir(parents=True, exist_ok=True)
591
+ shutil.copy(downloaded, DEFAULT_WEIGHTS)
592
+
593
+ def _html(fragment: str) -> str:
594
+ return textwrap.dedent(fragment).strip()
595
+
596
+
597
+ def md(html_content: str) -> None:
598
+ st.markdown(_html(html_content), unsafe_allow_html=True)
599
+
600
+
601
+ def _init_wizard_state() -> None:
602
+ defaults: dict[str, Any] = {
603
+ "wizard_step": 1,
604
+ "yolo_conf": 0.05,
605
+ "yolo_imgsz": 512,
606
+ "main_plate_grams": 500,
607
+ "portion_preset": "Medium (500g)",
608
+ "analysis_status": "idle",
609
+ "manual_mode": False,
610
+ "gemini_runtime_error": None,
611
+ "last_analysis_error": None,
612
+ }
613
+ for key, val in defaults.items():
614
+ st.session_state.setdefault(key, val)
615
+
616
+
617
+ def go_to_step(step: int) -> None:
618
+ st.session_state.wizard_step = max(1, min(TOTAL_WIZARD_STEPS, step))
619
+ st.rerun()
620
+
621
+
622
+ def reset_wizard() -> None:
623
+ for key in (
624
+ "upload_bytes",
625
+ "upload_name",
626
+ "analysis_result",
627
+ "image_bytes",
628
+ "grams_by_class",
629
+ "last_analysis_error",
630
+ "gemini_runtime_error",
631
+ ):
632
+ st.session_state.pop(key, None)
633
+ st.session_state.wizard_step = 1
634
+ st.session_state.analysis_status = "idle"
635
+ st.session_state.manual_mode = False
636
+ st.rerun()
637
+
638
+
639
+ def render_wizard_progress(current: int) -> None:
640
+ pct = current / TOTAL_WIZARD_STEPS
641
+ st.progress(pct, text=f"Step {current} of {TOTAL_WIZARD_STEPS}")
642
+ dots = []
643
+ for i in range(1, TOTAL_WIZARD_STEPS + 1):
644
+ if i < current:
645
+ cls = "wizard-dot done"
646
+ elif i == current:
647
+ cls = "wizard-dot active"
648
+ else:
649
+ cls = "wizard-dot"
650
+ dots.append(f'<span class="{cls}" title="{html.escape(WIZARD_STEP_NAMES[i - 1])}"></span>')
651
+ names = " · ".join(
652
+ f'<span style="color:{"#007AFF" if i + 1 == current else "#8E8E93"};font-weight:{"700" if i + 1 == current else "500"};">'
653
+ f"{html.escape(name)}</span>"
654
+ for i, name in enumerate(WIZARD_STEP_NAMES)
655
+ )
656
+ md(
657
+ f"""
658
+ <div class="wizard-dots">{"".join(dots)}</div>
659
+ <p class="wizard-progress-label">{names}</p>
660
+ """
661
+ )
662
+
663
+
664
+ def render_wizard_header(title: str, subtitle: str = "") -> None:
665
+ sub = f'<p class="wizard-subtitle">{html.escape(subtitle)}</p>' if subtitle else ""
666
+ md(
667
+ f"""
668
+ <div class="wizard-page">
669
+ <h1 class="wizard-title">{html.escape(title)}</h1>
670
+ {sub}
671
+ </div>
672
+ """
673
+ )
674
+
675
+
676
+ def wizard_nav(
677
+ *,
678
+ show_back: bool = True,
679
+ show_next: bool = True,
680
+ next_label: str = "Next →",
681
+ next_disabled: bool = False,
682
+ next_key: str = "wizard_next",
683
+ back_key: str = "wizard_back",
684
+ center_extra: Any = None,
685
+ ) -> bool:
686
+ """Render Back / optional center / Next. Returns True if Next was clicked."""
687
+ c_back, c_mid, c_next = st.columns([1, 2, 1])
688
+ with c_back:
689
+ if show_back and st.button("← Back", use_container_width=True, key=back_key):
690
+ go_to_step(int(st.session_state.wizard_step) - 1)
691
+ with c_mid:
692
+ if center_extra is not None:
693
+ center_extra()
694
+ with c_next:
695
+ if show_next:
696
+ clicked = st.button(
697
+ next_label,
698
+ type="primary",
699
+ use_container_width=True,
700
+ disabled=next_disabled,
701
+ key=next_key,
702
+ )
703
+ return bool(clicked)
704
+ return False
705
+
706
+
707
+ def load_api_keys_env() -> None:
708
+ env_file = PROJECT_ROOT / "api_keys.env"
709
+ if not env_file.exists():
710
+ return
711
+ for line in env_file.read_text(encoding="utf-8").splitlines():
712
+ line = line.strip()
713
+ if not line or line.startswith("#") or "=" not in line:
714
+ continue
715
+ key, _, value = line.partition("=")
716
+ key = key.removeprefix("export ").strip()
717
+ value = value.strip().strip("'").strip('"')
718
+ os.environ.setdefault(key, value)
719
+
720
+
721
+ def configure_ssl() -> None:
722
+ if os.environ.get("SSL_CERT_FILE"):
723
+ return
724
+ try:
725
+ import certifi
726
+
727
+ os.environ["SSL_CERT_FILE"] = certifi.where()
728
+ except ImportError:
729
+ pass
730
+
731
+
732
+ def inject_theme() -> None:
733
+ md(f"<style>{THEME_CSS}</style>")
734
+
735
+
736
+ @st.cache_resource
737
+ def get_yolo_model():
738
+ return load_yolo_model()
739
+
740
+
741
+ def _meal_score(totals: dict[str, float]) -> tuple[int, str, str]:
742
+ weights = {"kcal": 0.25, "protein": 0.3, "fat": 0.2, "carbs": 0.25}
743
+ score = 0.0
744
+ for key, weight in weights.items():
745
+ ref = float(NUTRIENT_GOALS[key]["goal"])
746
+ val = float(totals.get(key, 0))
747
+ inverse = bool(NUTRIENT_GOALS[key].get("inverse"))
748
+ ratio = val / ref if ref else 0
749
+ if inverse:
750
+ part = max(0.0, 100.0 - max(0.0, ratio - 0.5) * 80)
751
+ else:
752
+ part = max(0.0, 100.0 - abs(ratio - 0.75) * 90)
753
+ score += part * weight
754
+ score_int = int(max(0, min(100, round(score))))
755
+ if score_int >= 70:
756
+ label, css = "Excellent balance", "score-good"
757
+ elif score_int >= 45:
758
+ label, css = "Fair balance", "score-mid"
759
+ else:
760
+ label, css = "Poor balance", "score-bad"
761
+ return score_int, label, css
762
+
763
+
764
+ def _nutrient_rating(key: str, value: float) -> tuple[str, str]:
765
+ ref = float(NUTRIENT_GOALS[key]["goal"])
766
+ if ref <= 0:
767
+ return "—", "chip-mid"
768
+ ratio = value / ref
769
+ inverse = bool(NUTRIENT_GOALS[key].get("inverse"))
770
+ if inverse:
771
+ if ratio <= 0.85:
772
+ return "Good", "chip-good"
773
+ if ratio <= 1.15:
774
+ return "Moderate", "chip-mid"
775
+ return "High", "chip-low"
776
+ if ratio >= 0.65 and ratio <= 1.1:
777
+ return "Good", "chip-good"
778
+ if ratio >= 0.35:
779
+ return "Moderate" if ratio < 0.65 else "High", "chip-mid"
780
+ return "Low", "chip-low"
781
+
782
+
783
+ def _score_factors(totals: dict[str, float]) -> list[tuple[str, str, str]]:
784
+ labels = {
785
+ "kcal": "Calories",
786
+ "protein": "Protein",
787
+ "fat": "Fat",
788
+ "carbs": "Carbs",
789
+ }
790
+ return [
791
+ (labels[key], *_nutrient_rating(key, float(totals.get(key, 0))))
792
+ for key in ("protein", "carbs", "fat", "kcal")
793
+ ]
794
+
795
+
796
+ def _score_hint(totals: dict[str, float], grams_by_class: dict[str, float]) -> str:
797
+ veg = float(grams_by_class.get("vegetables", 0))
798
+ protein = float(totals.get("protein", 0))
799
+ carbs = float(totals.get("carbs", 0))
800
+ parts: list[str] = []
801
+ if protein >= float(NUTRIENT_GOALS["protein"]["goal"]) * 0.7:
802
+ parts.append("solid protein")
803
+ else:
804
+ parts.append("lower protein")
805
+ if carbs < float(NUTRIENT_GOALS["carbs"]["goal"]) * 0.4:
806
+ parts.append("fewer carbs detected")
807
+ elif carbs > float(NUTRIENT_GOALS["carbs"]["goal"]) * 1.2:
808
+ parts.append("higher carbs")
809
+ if veg < 30:
810
+ parts.append("limited vegetables")
811
+ elif veg >= 80:
812
+ parts.append("good vegetable portion")
813
+ if not parts:
814
+ return "Review the estimated breakdown below to improve accuracy."
815
+ return f"{' · '.join(parts).capitalize()}. Adjust portions below if needed."
816
+
817
+
818
+ def _macro_table_from_result(result: dict[str, Any]) -> dict[str, dict[str, float]]:
819
+ table: dict[str, dict[str, float]] = {}
820
+ for row in result.get("macros_per_100g", []):
821
+ name = str(row["class_name"])
822
+ table[name] = {
823
+ "kcal": float(row.get("kcal") or 0),
824
+ "protein": float(row.get("protein") or 0),
825
+ "fat": float(row.get("fat") or 0),
826
+ "carbs": float(row.get("carbs") or 0),
827
+ }
828
+ return table
829
+
830
+
831
+ def _initial_grams_by_class(result: dict[str, Any], total_plate_grams: float) -> dict[str, float]:
832
+ segments = result["segments"]["segments"]
833
+ portions = yaml.safe_load(DEFAULT_PORTIONS.read_text(encoding="utf-8"))
834
+ portions = {**portions, "total_plate_grams": float(total_plate_grams)}
835
+ grams = estimate_grams(segments, portions)
836
+ for cls in CLASS_ORDER:
837
+ grams.setdefault(cls, 0.0)
838
+ return grams
839
+
840
+
841
+ def _recalculate_macros(
842
+ grams_by_class: dict[str, float],
843
+ macro_table: dict[str, dict[str, float]],
844
+ ) -> tuple[list[dict[str, Any]], dict[str, float]]:
845
+ active = {k: v for k, v in grams_by_class.items() if v > 0 and k in macro_table}
846
+ if not active:
847
+ return [], {"grams": 0.0, "kcal": 0.0, "protein": 0.0, "fat": 0.0, "carbs": 0.0}
848
+ items, totals = estimate_macros(active, macro_table)
849
+ return items, totals
850
+
851
+
852
+ def _class_detected(segment_by_class: dict[str, Any], cls: str, grams: float) -> bool:
853
+ seg = segment_by_class.get(cls)
854
+ if seg and float(seg.get("area_fraction") or 0) > 0.01:
855
+ return True
856
+ return grams > 0
857
+
858
+
859
+ def _render_nutrient_bar(key: str, value: float) -> str:
860
+ meta = NUTRIENT_GOALS[key]
861
+ goal = float(meta["goal"])
862
+ unit = str(meta["unit"])
863
+ label = str(meta["label"])
864
+ icon = str(meta["icon"])
865
+ pct = min(100.0, round((value / goal) * 100)) if goal else 0
866
+ bar_pct = min(100.0, (value / goal) * 100) if goal else 0
867
+ return _html(
868
+ f"""
869
+ <div class="nutrient-row">
870
+ <div class="nutrient-head">
871
+ <span class="name">{icon} {html.escape(label)}</span>
872
+ <span class="vals">
873
+ {round(value):,} {html.escape(unit)}
874
+ <span class="pct">{pct:.0f}%</span>
875
+ </span>
876
+ </div>
877
+ <div class="bar-track">
878
+ <div class="bar-fill" style="width:{bar_pct:.0f}%"></div>
879
+ </div>
880
+ <div class="nutrient-goal">of {goal:,.0f} {html.escape(unit)} goal</div>
881
+ </div>
882
+ """
883
+ )
884
+
885
+
886
+ def _gemini_status_label(
887
+ *,
888
+ key_configured: bool,
889
+ use_gemini: bool,
890
+ runtime_error: str | None,
891
+ manual_mode: bool,
892
+ ) -> tuple[str, str]:
893
+ if not use_gemini:
894
+ return "Disabled", "dot-gray"
895
+ if not key_configured:
896
+ return "Not configured", "dot-gray"
897
+ if runtime_error or manual_mode:
898
+ return "Error", "dot-red"
899
+ return "Connected", "dot-green"
900
+
901
+
902
+ def render_sidebar_settings() -> tuple[bool, bool, float, int]:
903
+ st.markdown("### Scan settings")
904
+ use_gemini = st.toggle("Gemini food ID", value=True, help="Identify foods with Gemini Vision")
905
+ use_usda = st.toggle("USDA lookup", value=True, help="Fetch nutrition from USDA FoodData Central")
906
+
907
+ st.divider()
908
+ render_api_status(use_gemini=use_gemini, use_usda=use_usda)
909
+
910
+ st.divider()
911
+ with st.expander("Advanced settings", expanded=False):
912
+ conf = st.slider(
913
+ "Detection confidence",
914
+ min_value=0.01,
915
+ max_value=0.5,
916
+ value=float(st.session_state.get("yolo_conf", 0.05)),
917
+ step=0.01,
918
+ )
919
+ imgsz_options = [320, 512, 640]
920
+ saved_imgsz = int(st.session_state.get("yolo_imgsz", 512))
921
+ imgsz = st.selectbox(
922
+ "Image size (px)",
923
+ options=imgsz_options,
924
+ index=imgsz_options.index(saved_imgsz) if saved_imgsz in imgsz_options else 1,
925
+ )
926
+ st.session_state["yolo_conf"] = conf
927
+ st.session_state["yolo_imgsz"] = imgsz
928
+
929
+ return use_gemini, use_usda, conf, int(imgsz)
930
+
931
+
932
+ def render_api_status(*, use_gemini: bool, use_usda: bool) -> None:
933
+ st.markdown("**API status**")
934
+ gemini_key = bool(os.getenv("GEMINI_API_KEY"))
935
+ usda_key = bool(os.getenv("FDC_API_KEY"))
936
+ runtime_err = st.session_state.get("gemini_runtime_error")
937
+ manual = st.session_state.get("manual_mode", False)
938
+
939
+ g_label, g_dot = _gemini_status_label(
940
+ key_configured=gemini_key,
941
+ use_gemini=use_gemini,
942
+ runtime_error=runtime_err,
943
+ manual_mode=manual,
944
+ )
945
+ if not use_usda:
946
+ u_label, u_dot = "Disabled", "dot-gray"
947
+ elif usda_key:
948
+ u_label, u_dot = "Connected", "dot-green"
949
+ else:
950
+ u_label, u_dot = "Not configured", "dot-red"
951
+
952
+ md(
953
+ f"""
954
+ <div class="status-row" role="status">
955
+ <div class="status-item">
956
+ <span class="status-dot {g_dot}" aria-hidden="true"></span>
957
+ <span><strong>Gemini Vision:</strong> {html.escape(g_label)}</span>
958
+ </div>
959
+ <div class="status-item">
960
+ <span class="status-dot {u_dot}" aria-hidden="true"></span>
961
+ <span><strong>USDA Database:</strong> {html.escape(u_label)}</span>
962
+ </div>
963
+ </div>
964
+ """
965
+ )
966
+
967
+
968
+ def render_analysis_status_alert(result: dict[str, Any] | None) -> None:
969
+ if result is None:
970
+ return
971
+
972
+ gemini_err = result.get("gemini_error")
973
+ pipeline_err = st.session_state.get("last_analysis_error")
974
+
975
+ if pipeline_err and not result.get("segments"):
976
+ md(
977
+ f"""
978
+ <div class="alert-user alert-error" role="alert">
979
+ <h4>Automatic analysis failed</h4>
980
+ <p>You can retry or enter the plate breakdown manually below.</p>
981
+ </div>
982
+ """
983
+ )
984
+ with st.expander("Technical details"):
985
+ st.code(str(pipeline_err))
986
+ return
987
+
988
+ if gemini_err:
989
+ st.session_state.gemini_runtime_error = str(gemini_err)
990
+ st.session_state.manual_mode = True
991
+ md(
992
+ """
993
+ <div class="alert-user" role="alert">
994
+ <h4>Automatic meal recognition unavailable</h4>
995
+ <p>We could not analyze the image automatically. You can retry or adjust
996
+ the estimated plate breakdown manually below.</p>
997
+ </div>
998
+ """
999
+ )
1000
+ with st.expander("Technical details"):
1001
+ st.code(str(gemini_err))
1002
+ elif st.session_state.get("manual_mode"):
1003
+ md(
1004
+ """
1005
+ <div class="alert-user" role="status">
1006
+ <h4>Manual breakdown mode</h4>
1007
+ <p>Automatic detection had issues earlier. Adjust portions below — nutrition
1008
+ updates as you edit.</p>
1009
+ </div>
1010
+ """
1011
+ )
1012
+
1013
+
1014
+ def render_meal_score_card(
1015
+ score: int,
1016
+ label: str,
1017
+ css_class: str,
1018
+ totals: dict[str, float],
1019
+ grams_by_class: dict[str, float],
1020
+ ) -> None:
1021
+ factors = _score_factors(totals)
1022
+ hint = _score_hint(totals, grams_by_class)
1023
+ chips = "".join(
1024
+ f'<span class="factor-chip {css}">{html.escape(name)}: {html.escape(rating)}</span>'
1025
+ for name, rating, css in factors
1026
+ )
1027
+ n_chips = "".join(
1028
+ f'<span class="n-chip">{html.escape(str(NUTRIENT_GOALS[k]["label"]))}: '
1029
+ f'{round(float(totals.get(k, 0))):,}</span>'
1030
+ for k in ("kcal", "protein", "carbs", "fat")
1031
+ )
1032
+ md(
1033
+ f"""
1034
+ <div class="glass-card">
1035
+ <p class="score-heading">Meal balance score</p>
1036
+ <div class="score-block">
1037
+ <div class="score-pulse-wrap">
1038
+ <div class="score-ring {css_class}">
1039
+ <span class="num">{score}</span>
1040
+ <span class="denom">/ 100</span>
1041
+ </div>
1042
+ </div>
1043
+ <p class="score-label-below {css_class}">{html.escape(label)}</p>
1044
+ <p class="score-hint">{html.escape(hint)}</p>
1045
+ <div class="factor-chips">{chips}</div>
1046
+ <p style="text-align:center;color:#8E8E93;font-size:0.92rem;margin:0;">
1047
+ Total portion: <strong style="color:#1D1D1F">{totals["grams"]:.0f} g</strong>
1048
+ </p>
1049
+ <div class="nutrient-chips">{n_chips}</div>
1050
+ </div>
1051
+ </div>
1052
+ """
1053
+ )
1054
+
1055
+
1056
+ def render_plate_breakdown_editor(
1057
+ result: dict[str, Any],
1058
+ total_plate_grams: float,
1059
+ macro_table: dict[str, dict[str, float]],
1060
+ *,
1061
+ show_section_header: bool = True,
1062
+ ) -> tuple[list[dict[str, Any]], dict[str, float], dict[str, float]]:
1063
+ segments = result["segments"]["segments"]
1064
+ segment_by_class = {str(s["class_name"]): s for s in segments}
1065
+
1066
+ if "grams_by_class" not in st.session_state:
1067
+ st.session_state.grams_by_class = _initial_grams_by_class(result, total_plate_grams)
1068
+
1069
+ gemini_by_class: dict[str, str] = {}
1070
+ if result.get("gemini_analysis"):
1071
+ for comp in result["gemini_analysis"].get("components", []):
1072
+ gemini_by_class[str(comp.get("class_name", ""))] = str(
1073
+ comp.get("likely_food") or comp.get("fdc_query") or ""
1074
+ )
1075
+
1076
+ if show_section_header:
1077
+ md('<p class="section-header">Estimated plate breakdown</p>')
1078
+
1079
+ action_cols = st.columns([1, 1, 2])
1080
+ with action_cols[0]:
1081
+ reset = st.button("Reset breakdown", use_container_width=True, key="btn_reset_breakdown")
1082
+ with action_cols[1]:
1083
+ st.caption("Edit detected foods below")
1084
+
1085
+ if reset:
1086
+ st.session_state.grams_by_class = _initial_grams_by_class(result, total_plate_grams)
1087
+ st.rerun()
1088
+
1089
+ grams_by_class: dict[str, float] = {}
1090
+ for cls in CLASS_ORDER:
1091
+ grams_by_class[cls] = float(st.session_state.grams_by_class.get(cls, 0))
1092
+
1093
+ assigned = sum(grams_by_class.values())
1094
+ other_g = max(0.0, float(total_plate_grams) - assigned)
1095
+
1096
+ summary_rows = "".join(
1097
+ f'<span class="label">{CLASS_META[c]["icon"]} {html.escape(CLASS_META[c]["label"])}</span>'
1098
+ f'<span class="val">{grams_by_class[c]:.0f} g</span>'
1099
+ for c in CLASS_ORDER
1100
+ )
1101
+ if other_g > 0.5:
1102
+ summary_rows += (
1103
+ f'<span class="label">Other / unassigned</span>'
1104
+ f'<span class="val">{other_g:.0f} g</span>'
1105
+ )
1106
+
1107
+ md(
1108
+ f"""
1109
+ <div class="glass-card">
1110
+ <p style="font-weight:600;color:#1D1D1F;margin:0 0 0.75rem;">Detected foods</p>
1111
+ <div class="breakdown-summary">{summary_rows}</div>
1112
+ <p class="breakdown-assigned">
1113
+ Assigned: <strong>{assigned:.0f} g</strong> / {total_plate_grams:.0f} g
1114
+ · Remaining: <strong>{other_g:.0f} g</strong>
1115
+ </p>
1116
+ </div>
1117
+ """
1118
+ )
1119
+
1120
+ md('<div class="glass-card">')
1121
+
1122
+ for cls in CLASS_ORDER:
1123
+ meta = CLASS_META[cls]
1124
+ detected = _class_detected(segment_by_class, cls, grams_by_class[cls])
1125
+ default_g = int(round(float(st.session_state.grams_by_class.get(cls, 0))))
1126
+ food_label = gemini_by_class.get(cls) or meta["label"]
1127
+
1128
+ head_cols = st.columns([2, 1, 1])
1129
+ with head_cols[0]:
1130
+ st.markdown(f"**{meta['icon']} {food_label}**")
1131
+ if not detected and default_g == 0:
1132
+ st.caption("Not detected")
1133
+ with head_cols[1]:
1134
+ grams_val = st.number_input(
1135
+ f"{meta['label']} grams",
1136
+ min_value=0,
1137
+ max_value=CLASS_GRAMS_MAX,
1138
+ value=default_g,
1139
+ step=5,
1140
+ key=f"grams_num_{cls}",
1141
+ label_visibility="collapsed",
1142
+ )
1143
+ with head_cols[2]:
1144
+ st.markdown("<span style='color:#8E8E93;font-size:0.85rem'>g</span>", unsafe_allow_html=True)
1145
+
1146
+ grams_by_class[cls] = float(
1147
+ st.slider(
1148
+ f"{meta['label']} slider",
1149
+ min_value=0,
1150
+ max_value=CLASS_GRAMS_MAX,
1151
+ value=int(grams_val),
1152
+ step=5,
1153
+ key=f"grams_slider_{cls}",
1154
+ label_visibility="collapsed",
1155
+ )
1156
+ )
1157
+
1158
+ if not detected and grams_by_class[cls] == 0:
1159
+ if st.button(f"Add {meta['label'].lower()}", key=f"btn_add_{cls}"):
1160
+ st.session_state.grams_by_class[cls] = min(75, int(total_plate_grams * 0.15))
1161
+ st.rerun()
1162
+
1163
+ st.markdown("<div style='height:0.25rem'></div>", unsafe_allow_html=True)
1164
+
1165
+ md("</div>")
1166
+
1167
+ total_slider_g = sum(grams_by_class.values()) or 1.0
1168
+ bar_parts = ['<div class="glass-card" style="margin-top:-0.5rem;padding-top:0.5rem;">']
1169
+ for cls in CLASS_ORDER:
1170
+ meta = CLASS_META[cls]
1171
+ share_pct = (grams_by_class[cls] / total_slider_g) * 100
1172
+ accent = meta["accent"]
1173
+ bar_parts.append(
1174
+ f'<p style="font-size:0.88rem;color:#636366;margin:0 0 0.25rem;">'
1175
+ f'{meta["icon"]} {html.escape(meta["label"])} · '
1176
+ f'<strong>{share_pct:.0f}%</strong> · {grams_by_class[cls]:.0f} g</p>'
1177
+ f'<div class="plate-bar-track"><div class="plate-bar-fill" '
1178
+ f'style="width:{min(share_pct, 100):.1f}%;--accent:{accent};--accent-light:{accent}99;">'
1179
+ f"</div></div><div style='height:0.85rem'></div>"
1180
+ )
1181
+ bar_parts.append("</div>")
1182
+ st.markdown("".join(bar_parts), unsafe_allow_html=True)
1183
+ st.session_state.grams_by_class = grams_by_class
1184
+ items, totals = _recalculate_macros(grams_by_class, macro_table)
1185
+ return items, totals, grams_by_class
1186
+
1187
+
1188
+ def _sync_grams_and_totals(
1189
+ result: dict[str, Any],
1190
+ total_plate_grams: float,
1191
+ macro_table: dict[str, dict[str, float]],
1192
+ ) -> tuple[list[dict[str, Any]], dict[str, float], dict[str, float]]:
1193
+ if "grams_by_class" not in st.session_state:
1194
+ st.session_state.grams_by_class = _initial_grams_by_class(result, total_plate_grams)
1195
+ grams = {k: float(st.session_state.grams_by_class.get(k, 0)) for k in CLASS_ORDER}
1196
+ items, totals = _recalculate_macros(grams, macro_table)
1197
+ return items, totals, grams
1198
+
1199
+
1200
+ def wizard_step_1_upload() -> None:
1201
+ render_wizard_progress(1)
1202
+ render_wizard_header(
1203
+ "Upload your meal photo",
1204
+ "Drag and drop a top-down plate photo, or click to browse.",
1205
+ )
1206
+
1207
+ if not st.session_state.get("upload_bytes"):
1208
+ md(
1209
+ """
1210
+ <div class="wizard-upload-zone wizard-center">
1211
+ <h3>📷 Upload meal photo</h3>
1212
+ <p>Drag and drop an image here, or click to browse</p>
1213
+ <p class="hint" style="color:#8E8E93;font-size:0.88rem;margin-top:0.75rem;">
1214
+ Supports JPG, PNG, WEBP, and HEIC
1215
+ </p>
1216
+ </div>
1217
+ """
1218
+ )
1219
+
1220
+ uploaded = st.file_uploader(
1221
+ "Upload meal photo",
1222
+ type=["jpg", "jpeg", "png", "webp", "heic", "heif", "bmp", "tiff", "tif", "gif"],
1223
+ label_visibility="collapsed",
1224
+ key="wizard_file_uploader",
1225
+ )
1226
+ if uploaded is not None:
1227
+ st.session_state.upload_bytes = uploaded.getvalue()
1228
+ st.session_state.upload_name = uploaded.name
1229
+
1230
+ # --- Or load an image from a folder on this PC (local app only) ---
1231
+ with st.expander("📁 …or open a folder of photos"):
1232
+ folder = st.text_input(
1233
+ "Folder path",
1234
+ key="folder_path_input",
1235
+ placeholder=r"D:\my_meal_photos",
1236
+ )
1237
+ if folder:
1238
+ folder_path = Path(folder)
1239
+ if not folder_path.is_dir():
1240
+ st.warning("That folder doesn't exist (or isn't a folder).")
1241
+ else:
1242
+ exts = (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".gif")
1243
+ images = sorted(
1244
+ p for p in folder_path.iterdir() if p.suffix.lower() in exts
1245
+ )
1246
+ if not images:
1247
+ st.info("No image files found in that folder.")
1248
+ else:
1249
+ chosen = st.selectbox(
1250
+ f"{len(images)} image(s) found — pick one",
1251
+ images,
1252
+ format_func=lambda p: p.name,
1253
+ key="folder_image_select",
1254
+ )
1255
+ if st.button("Use this image", key="folder_use_btn"):
1256
+ st.session_state.upload_bytes = chosen.read_bytes()
1257
+ st.session_state.upload_name = chosen.name
1258
+ st.rerun()
1259
+
1260
+ has_image = bool(st.session_state.get("upload_bytes"))
1261
+ if has_image:
1262
+ name = str(st.session_state.get("upload_name", "meal.jpg"))
1263
+ img = Image.open(io.BytesIO(st.session_state.upload_bytes))
1264
+ img.thumbnail((640, 640))
1265
+ buf = io.BytesIO()
1266
+ img.save(buf, format="JPEG", quality=90)
1267
+ b64 = base64.b64encode(buf.getvalue()).decode()
1268
+ md(
1269
+ f"""
1270
+ <div class="wizard-preview">
1271
+ <img src="data:image/jpeg;base64,{b64}" alt="Meal preview" />
1272
+ <p style="margin-top:1rem;color:#636366;">
1273
+ <strong style="color:#1D1D1F">{html.escape(name)}</strong>
1274
+ </p>
1275
+ </div>
1276
+ """
1277
+ )
1278
+
1279
+ st.markdown("<div style='height:2rem'></div>", unsafe_allow_html=True)
1280
+ if wizard_nav(
1281
+ show_back=False,
1282
+ next_label="Next →",
1283
+ next_disabled=not has_image,
1284
+ next_key="w1_next",
1285
+ ):
1286
+ go_to_step(2)
1287
+
1288
+
1289
+ def wizard_step_2_plate() -> None:
1290
+ render_wizard_progress(2)
1291
+ render_wizard_header(
1292
+ "Plate settings",
1293
+ "Set the total weight of food on your plate for portion estimates.",
1294
+ )
1295
+
1296
+ weight = int(st.session_state.get("main_plate_grams", 500))
1297
+ md(f'<p class="wizard-weight-display">{weight}<span style="font-size:1.5rem;color:#636366"> g</span></p>')
1298
+
1299
+ st.markdown("<p style='text-align:center;color:#636366;margin-bottom:0.75rem;'>Portion size</p>", unsafe_allow_html=True)
1300
+ p1, p2, p3 = st.columns(3)
1301
+ presets = (("Small", 300), ("Medium", 500), ("Large", 750))
1302
+ for col, (label, grams) in zip((p1, p2, p3), presets):
1303
+ with col:
1304
+ if st.button(f"{label}\n{grams} g", use_container_width=True, key=f"preset_{grams}"):
1305
+ st.session_state.main_plate_grams = grams
1306
+ st.session_state.portion_preset = f"{label} ({grams}g)"
1307
+ st.rerun()
1308
+
1309
+ weight = st.slider(
1310
+ "Total plate weight",
1311
+ min_value=PLATE_WEIGHT_MIN,
1312
+ max_value=PLATE_WEIGHT_MAX,
1313
+ value=int(st.session_state.get("main_plate_grams", 500)),
1314
+ step=25,
1315
+ key="wizard_plate_slider",
1316
+ )
1317
+ st.session_state.main_plate_grams = int(weight)
1318
+
1319
+ st.markdown("<div style='height:2rem'></div>", unsafe_allow_html=True)
1320
+ if wizard_nav(show_back=True, next_label="Next →", next_key="w2_next"):
1321
+ go_to_step(3)
1322
+
1323
+
1324
+ def wizard_step_3_analyze(
1325
+ *,
1326
+ use_gemini: bool,
1327
+ use_usda: bool,
1328
+ yolo_conf: float,
1329
+ yolo_imgsz: int,
1330
+ ) -> None:
1331
+ render_wizard_progress(3)
1332
+ render_wizard_header(
1333
+ "Analyze your meal",
1334
+ "We'll segment your plate and estimate nutrition.",
1335
+ )
1336
+
1337
+ if not st.session_state.get("upload_bytes"):
1338
+ st.warning("Please upload a photo first.")
1339
+ wizard_nav(show_back=True, show_next=False)
1340
+ return
1341
+
1342
+ plate_grams = float(st.session_state.get("main_plate_grams", 500))
1343
+ st.caption(f"Plate weight: **{plate_grams:.0f} g** · Ready to analyze")
1344
+
1345
+ if st.session_state.get("last_analysis_error"):
1346
+ st.error("Analysis failed. Try again or go back to adjust settings.")
1347
+ with st.expander("Technical details"):
1348
+ st.code(st.session_state.last_analysis_error)
1349
+
1350
+ md('<div class="analyze-hero wizard-center">')
1351
+
1352
+ analyze_clicked = st.button(
1353
+ "Analyze my meal",
1354
+ type="primary",
1355
+ use_container_width=True,
1356
+ key="wizard_analyze_btn",
1357
+ )
1358
+
1359
+ if analyze_clicked:
1360
+ with st.spinner("Analyzing image… Identifying foods and estimating portions."):
1361
+ ok = run_analysis(
1362
+ st.session_state.upload_bytes,
1363
+ str(st.session_state.get("upload_name", "meal.jpg")),
1364
+ use_gemini=use_gemini,
1365
+ use_usda=use_usda,
1366
+ plate_grams=plate_grams,
1367
+ yolo_conf=yolo_conf,
1368
+ yolo_imgsz=yolo_imgsz,
1369
+ )
1370
+ if ok:
1371
+ go_to_step(4)
1372
+ else:
1373
+ st.rerun()
1374
+
1375
+ md("</div>")
1376
+ st.markdown("<div style='height:1.5rem'></div>", unsafe_allow_html=True)
1377
+ wizard_nav(show_back=True, show_next=False, back_key="w3_back")
1378
+
1379
+
1380
+ def wizard_step_4_results(
1381
+ result: dict[str, Any],
1382
+ image_bytes: bytes,
1383
+ total_plate_grams: float,
1384
+ ) -> None:
1385
+ render_wizard_progress(4)
1386
+ render_wizard_header(
1387
+ "Your results",
1388
+ "Review nutrition and adjust the plate breakdown if needed.",
1389
+ )
1390
+
1391
+ macro_table = _macro_table_from_result(result)
1392
+ render_analysis_status_alert(result)
1393
+
1394
+ if not result.get("gemini_error") and not st.session_state.get("last_analysis_error"):
1395
+ md('<div class="pill pill-ok">✓ Meal analyzed successfully</div>')
1396
+
1397
+ if result.get("gemini_analysis"):
1398
+ summary = str(result["gemini_analysis"].get("meal_summary", ""))[:80]
1399
+ if summary:
1400
+ md(
1401
+ f'<p style="text-align:center;font-size:1.05rem;font-weight:600;'
1402
+ f'color:#1D1D1F;margin:0 0 1.25rem;">{html.escape(summary)}</p>'
1403
+ )
1404
+
1405
+ score_top = st.container()
1406
+ plate_section = st.container()
1407
+
1408
+ with plate_section:
1409
+ items, totals, grams_by_class = render_plate_breakdown_editor(
1410
+ result,
1411
+ total_plate_grams,
1412
+ macro_table,
1413
+ show_section_header=True,
1414
+ )
1415
+
1416
+ score, score_label, score_css = _meal_score(totals)
1417
+ with score_top:
1418
+ col_score, col_bars = st.columns([1, 1.35])
1419
+ with col_score:
1420
+ render_meal_score_card(score, score_label, score_css, totals, grams_by_class)
1421
+ with col_bars:
1422
+ md('<p class="section-header" style="margin-top:0;">Nutrients</p>')
1423
+ nutrients_html = '<div class="glass-card">'
1424
+ for key in ("kcal", "protein", "fat", "carbs"):
1425
+ nutrients_html += _render_nutrient_bar(key, float(totals.get(key, 0)))
1426
+ nutrients_html += "</div>"
1427
+ st.markdown(nutrients_html, unsafe_allow_html=True)
1428
+
1429
+ st.markdown("<div style='height:1.75rem'></div>", unsafe_allow_html=True)
1430
+
1431
+ st.markdown("<div style='height:1.75rem'></div>", unsafe_allow_html=True)
1432
+ md('<p class="section-header">Meal photo</p>')
1433
+ view = st.radio(
1434
+ "Photo view",
1435
+ options=["Original", "Segmentation"],
1436
+ horizontal=True,
1437
+ label_visibility="collapsed",
1438
+ key="wizard_photo_view",
1439
+ )
1440
+ if view == "Original":
1441
+ st.image(Image.open(io.BytesIO(image_bytes)), use_container_width=True)
1442
+ else:
1443
+ overlay_rgb = cv2.cvtColor(result["overlay_bgr"], cv2.COLOR_BGR2RGB)
1444
+ st.image(overlay_rgb, use_container_width=True)
1445
+
1446
+ st.markdown("<div style='height:1.5rem'></div>", unsafe_allow_html=True)
1447
+ nav1, nav2, nav3 = st.columns(3)
1448
+ with nav1:
1449
+ if st.button("↺ Start over", use_container_width=True, key="w4_start_over"):
1450
+ reset_wizard()
1451
+ with nav2:
1452
+ if st.button("View nutrition details →", use_container_width=True, key="w4_details"):
1453
+ go_to_step(5)
1454
+ with nav3:
1455
+ if st.button("← Back", use_container_width=True, key="w4_back"):
1456
+ go_to_step(3)
1457
+
1458
+
1459
+ def wizard_step_5_details(result: dict[str, Any], total_plate_grams: float) -> None:
1460
+ render_wizard_progress(5)
1461
+ render_wizard_header(
1462
+ "Nutrition details",
1463
+ "Per-class breakdown and per 100 g reference values.",
1464
+ )
1465
+
1466
+ macro_table = _macro_table_from_result(result)
1467
+ items, totals, _ = _sync_grams_and_totals(result, total_plate_grams, macro_table)
1468
+
1469
+ st.markdown("#### Per-class nutrition")
1470
+ if items:
1471
+ st.dataframe(pd.DataFrame(items), hide_index=True, use_container_width=True)
1472
+ else:
1473
+ st.info("Assign food weights on the results step to see per-class nutrition.")
1474
+
1475
+ st.markdown("#### Per 100 g reference")
1476
+ st.dataframe(pd.DataFrame(result["macros_per_100g"]), hide_index=True, use_container_width=True)
1477
+
1478
+ st.markdown("<div style='height:2rem'></div>", unsafe_allow_html=True)
1479
+ if wizard_nav(show_back=True, show_next=False, back_key="w5_back"):
1480
+ go_to_step(4)
1481
+
1482
+
1483
+ def run_analysis(
1484
+ image_bytes: bytes,
1485
+ filename: str,
1486
+ *,
1487
+ use_gemini: bool,
1488
+ use_usda: bool,
1489
+ plate_grams: float,
1490
+ yolo_conf: float,
1491
+ yolo_imgsz: int,
1492
+ ) -> bool:
1493
+ st.session_state.analysis_status = "loading"
1494
+ st.session_state.last_analysis_error = None
1495
+ suffix = Path(filename).suffix or ".jpg"
1496
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
1497
+ tmp.write(image_bytes)
1498
+ image_path = Path(tmp.name)
1499
+ try:
1500
+ analysis = run_meal_analysis(
1501
+ image_path,
1502
+ use_gemini=use_gemini,
1503
+ use_usda=use_usda,
1504
+ total_plate_grams=float(plate_grams),
1505
+ yolo_model=get_yolo_model(),
1506
+ yolo_conf=yolo_conf,
1507
+ yolo_imgsz=yolo_imgsz,
1508
+ )
1509
+ st.session_state.analysis_result = analysis
1510
+ st.session_state.image_bytes = image_bytes
1511
+ st.session_state.analysis_status = "success"
1512
+ if analysis.get("gemini_error"):
1513
+ st.session_state.gemini_runtime_error = str(analysis["gemini_error"])
1514
+ st.session_state.manual_mode = True
1515
+ else:
1516
+ st.session_state.gemini_runtime_error = None
1517
+ st.session_state.manual_mode = False
1518
+ st.session_state.pop("grams_by_class", None)
1519
+ return True
1520
+ except Exception as exc:
1521
+ st.session_state.analysis_status = "error"
1522
+ st.session_state.last_analysis_error = str(exc)
1523
+ st.set_page_config(page_title="Meal Scan", page_icon="🥗", layout="wide", initial_sidebar_state="expanded")
1524
+ return False
1525
+ finally:
1526
+ image_path.unlink(missing_ok=True)
1527
+
1528
+
1529
+ def main() -> None:
1530
+ st.set_page_config(page_title="Meal Scan", page_icon="🥗", layout="wide")
1531
+ inject_theme()
1532
+ load_api_keys_env()
1533
+ configure_ssl()
1534
+ ensure_weights()
1535
+
1536
+ if not DEFAULT_WEIGHTS.exists() or get_yolo_model() is None:
1537
+ st.error("YOLO model weights not found.")
1538
+ st.stop()
1539
+
1540
+ _init_wizard_state()
1541
+
1542
+ with st.sidebar:
1543
+ use_gemini, use_usda, yolo_conf, yolo_imgsz = render_sidebar_settings()
1544
+
1545
+ md(
1546
+ """
1547
+ <div style="text-align:center;padding:0.25rem 0 0.5rem;">
1548
+ <p style="font-size:0.95rem;font-weight:600;color:#8E8E93;margin:0;
1549
+ letter-spacing:0.04em;text-transform:uppercase;">Meal Scan</p>
1550
+ </div>
1551
+ """
1552
+ )
1553
+
1554
+ step = int(st.session_state.get("wizard_step", 1))
1555
+
1556
+ if step == 1:
1557
+ wizard_step_1_upload()
1558
+ elif step == 2:
1559
+ wizard_step_2_plate()
1560
+ elif step == 3:
1561
+ wizard_step_3_analyze(
1562
+ use_gemini=use_gemini,
1563
+ use_usda=use_usda,
1564
+ yolo_conf=yolo_conf,
1565
+ yolo_imgsz=yolo_imgsz,
1566
+ )
1567
+ elif step == 4:
1568
+ if "analysis_result" not in st.session_state:
1569
+ go_to_step(3)
1570
+ else:
1571
+ img = st.session_state.get("image_bytes") or st.session_state.get("upload_bytes")
1572
+ if img:
1573
+ wizard_step_4_results(
1574
+ st.session_state.analysis_result,
1575
+ img,
1576
+ float(st.session_state.get("main_plate_grams", 500)),
1577
+ )
1578
+ else:
1579
+ go_to_step(1)
1580
+ elif step == 5:
1581
+ if "analysis_result" not in st.session_state:
1582
+ go_to_step(3)
1583
+ else:
1584
+ wizard_step_5_details(
1585
+ st.session_state.analysis_result,
1586
+ float(st.session_state.get("main_plate_grams", 500)),
1587
+ )
1588
+ else:
1589
+ st.session_state.wizard_step = 1
1590
+ st.rerun()
1591
+
1592
+
1593
+ main()