Spaces:
Runtime error
Runtime error
| """Streamlit entry point: streamlit run app.py — meal nutrition scan UI.""" | |
| from __future__ import annotations | |
| import html | |
| import shutil | |
| import io | |
| import os | |
| import sys | |
| import tempfile | |
| import textwrap | |
| from pathlib import Path | |
| import base64 | |
| from typing import Any | |
| import cv2 | |
| import pandas as pd | |
| import streamlit as st | |
| import yaml | |
| from PIL import Image | |
| PROJECT_ROOT = Path(__file__).resolve().parent | |
| SCRIPT_DIR = PROJECT_ROOT / "scripts" | |
| if str(SCRIPT_DIR) not in sys.path: | |
| sys.path.insert(0, str(SCRIPT_DIR)) | |
| os.environ.setdefault("YOLO_CONFIG_DIR", str(PROJECT_ROOT / ".ultralytics")) | |
| os.environ.setdefault("MPLCONFIGDIR", str(PROJECT_ROOT / ".matplotlib")) | |
| from estimate_macros_from_segments import estimate_grams, estimate_macros # pyright: ignore[reportMissingImports] | |
| from meal_macro_pipeline import ( # pyright: ignore[reportMissingImports] | |
| DEFAULT_PORTIONS, | |
| DEFAULT_WEIGHTS, | |
| load_yolo_model, | |
| run_meal_analysis, | |
| ) | |
| ACCENT = "#007AFF" | |
| CLASS_ORDER = ("meat", "rice", "vegetables") | |
| PLATE_WEIGHT_MIN = 100 | |
| PLATE_WEIGHT_MAX = 1200 | |
| CLASS_GRAMS_MAX = 500 | |
| CLASS_META: dict[str, dict[str, str]] = { | |
| "meat": {"icon": "🥩", "label": "Meat", "accent": "#FF3B30"}, | |
| "rice": {"icon": "🍚", "label": "Rice", "accent": "#FF9500"}, | |
| "vegetables": {"icon": "🥬", "label": "Vegetables", "accent": "#34C759"}, | |
| } | |
| NUTRIENT_GOALS: dict[str, dict[str, float | str | bool]] = { | |
| "kcal": {"label": "Calories", "goal": 700, "unit": "kcal", "icon": "⚡"}, | |
| "protein": {"label": "Protein", "goal": 35, "unit": "g", "icon": "💪"}, | |
| "fat": {"label": "Fat", "goal": 25, "unit": "g", "icon": "🫒", "inverse": True}, | |
| "carbs": {"label": "Carbs", "goal": 90, "unit": "g", "icon": "🌾", "inverse": True}, | |
| } | |
| PORTION_PRESETS: dict[str, int | None] = { | |
| "Small (300g)": 300, | |
| "Medium (500g)": 500, | |
| "Large (750g)": 750, | |
| "Custom": None, | |
| } | |
| TOTAL_WIZARD_STEPS = 5 | |
| WIZARD_STEP_NAMES = ("Upload", "Plate", "Analyze", "Results", "Details") | |
| THEME_CSS = f""" | |
| @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&display=swap'); | |
| html, body, [class*="css"] {{ | |
| font-family: -apple-system, BlinkMacSystemFont, 'SF Pro Display', 'DM Sans', sans-serif !important; | |
| }} | |
| .stApp {{ | |
| background: linear-gradient(135deg, #f5f7fa 0%, #e8ecf0 100%) !important; | |
| background-attachment: fixed !important; | |
| }} | |
| .block-container {{ | |
| padding-top: 1rem !important; | |
| padding-bottom: 3rem !important; | |
| max-width: 920px !important; | |
| }} | |
| /* Wizard */ | |
| .wizard-progress-label {{ | |
| text-align: center; | |
| font-size: 0.88rem; | |
| font-weight: 600; | |
| color: #8E8E93; | |
| margin: 0.35rem 0 1.75rem 0; | |
| }} | |
| .wizard-dots {{ | |
| display: flex; | |
| justify-content: center; | |
| gap: 0.65rem; | |
| margin: 0.75rem 0 0.5rem; | |
| }} | |
| .wizard-dot {{ | |
| width: 11px; | |
| height: 11px; | |
| border-radius: 50%; | |
| background: #D1D1D6; | |
| transition: background 0.25s ease, transform 0.25s ease; | |
| }} | |
| .wizard-dot.active {{ | |
| background: {ACCENT}; | |
| transform: scale(1.15); | |
| }} | |
| .wizard-dot.done {{ | |
| background: #34C759; | |
| }} | |
| .wizard-page {{ | |
| min-height: 52vh; | |
| padding: 0.5rem 0 2rem; | |
| }} | |
| .wizard-title {{ | |
| font-size: 2.15rem !important; | |
| font-weight: 700 !important; | |
| color: #1D1D1F !important; | |
| text-align: center; | |
| margin: 0 0 0.5rem 0 !important; | |
| letter-spacing: -0.03em !important; | |
| }} | |
| .wizard-subtitle {{ | |
| text-align: center; | |
| color: #636366; | |
| font-size: 1.1rem; | |
| margin: 0 0 2.25rem 0; | |
| line-height: 1.45; | |
| }} | |
| .wizard-center {{ | |
| max-width: 560px; | |
| margin: 0 auto; | |
| }} | |
| .wizard-upload-zone {{ | |
| text-align: center; | |
| padding: 3rem 2rem; | |
| border: 2px dashed rgba(0, 122, 255, 0.4); | |
| border-radius: 24px; | |
| background: rgba(255, 255, 255, 0.55); | |
| margin-bottom: 1.5rem; | |
| }} | |
| .wizard-upload-zone h3 {{ | |
| font-size: 1.35rem; | |
| font-weight: 700; | |
| color: #1D1D1F; | |
| margin: 0 0 0.5rem 0; | |
| }} | |
| .wizard-preview {{ | |
| text-align: center; | |
| margin: 1.5rem auto 2rem; | |
| max-width: 420px; | |
| }} | |
| .wizard-preview img {{ | |
| width: 100%; | |
| max-height: 320px; | |
| object-fit: cover; | |
| border-radius: 20px; | |
| box-shadow: 0 12px 40px rgba(0,0,0,0.1); | |
| border: 1px solid rgba(0,0,0,0.06); | |
| }} | |
| .wizard-weight-display {{ | |
| text-align: center; | |
| font-size: 3rem; | |
| font-weight: 700; | |
| color: {ACCENT}; | |
| margin: 0.5rem 0 0.25rem; | |
| letter-spacing: -0.03em; | |
| }} | |
| .wizard-preset-row {{ | |
| display: flex; | |
| gap: 0.75rem; | |
| justify-content: center; | |
| flex-wrap: wrap; | |
| margin: 1.5rem 0 2rem; | |
| }} | |
| .analyze-hero {{ | |
| text-align: center; | |
| padding: 3rem 1rem; | |
| }} | |
| @keyframes spin-ring {{ | |
| 0% {{ transform: rotate(0deg); }} | |
| 100% {{ transform: rotate(360deg); }} | |
| }} | |
| .spinner-ring {{ | |
| width: 56px; | |
| height: 56px; | |
| border: 4px solid rgba(0, 122, 255, 0.15); | |
| border-top-color: {ACCENT}; | |
| border-radius: 50%; | |
| animation: spin-ring 0.9s linear infinite; | |
| margin: 1.5rem auto; | |
| }} | |
| #MainMenu, footer, [data-testid="stToolbar"] {{ | |
| visibility: hidden; | |
| }} | |
| header[data-testid="stHeader"] {{ | |
| background: transparent !important; | |
| }} | |
| [data-testid="stSidebarCollapsedControl"] {{ | |
| visibility: visible !important; | |
| }} | |
| section[data-testid="stSidebar"] {{ | |
| background: rgba(255, 255, 255, 0.72) !important; | |
| backdrop-filter: blur(20px) !important; | |
| border-right: 1px solid rgba(0, 0, 0, 0.06) !important; | |
| }} | |
| .glass-card {{ | |
| background: rgba(255, 255, 255, 0.82); | |
| border: 1px solid rgba(0, 0, 0, 0.06); | |
| box-shadow: 0 4px 24px rgba(0, 0, 0, 0.06); | |
| border-radius: 18px; | |
| padding: 1.5rem; | |
| margin-bottom: 1.5rem; | |
| }} | |
| .step-block {{ | |
| margin-bottom: 2rem; | |
| }} | |
| .step-label {{ | |
| font-size: 0.8rem; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.06em; | |
| color: {ACCENT}; | |
| margin: 0 0 0.35rem 0; | |
| }} | |
| .step-title {{ | |
| font-size: 1.35rem; | |
| font-weight: 700; | |
| color: #1D1D1F; | |
| margin: 0 0 0.35rem 0; | |
| letter-spacing: -0.02em; | |
| }} | |
| .step-sub {{ | |
| font-size: 0.95rem; | |
| color: #636366; | |
| margin: 0 0 1rem 0; | |
| }} | |
| .section-header {{ | |
| font-size: 1.25rem; | |
| font-weight: 700; | |
| color: #1D1D1F; | |
| margin: 0 0 1rem 0; | |
| padding-left: 0.75rem; | |
| border-left: 4px solid {ACCENT}; | |
| }} | |
| .upload-empty {{ | |
| text-align: center; | |
| padding: 1.75rem 1.25rem; | |
| border: 2px dashed rgba(0, 122, 255, 0.35); | |
| border-radius: 16px; | |
| background: rgba(255, 255, 255, 0.5); | |
| }} | |
| .upload-empty h3 {{ | |
| font-size: 1.15rem; | |
| font-weight: 700; | |
| color: #1D1D1F; | |
| margin: 0 0 0.35rem 0; | |
| }} | |
| .upload-empty p {{ | |
| color: #636366; | |
| margin: 0.2rem 0; | |
| font-size: 0.95rem; | |
| }} | |
| .upload-empty .hint {{ | |
| font-size: 0.82rem; | |
| color: #8E8E93; | |
| margin-top: 0.5rem; | |
| }} | |
| .upload-preview-row {{ | |
| display: flex; | |
| gap: 1.25rem; | |
| align-items: flex-start; | |
| }} | |
| .upload-thumb {{ | |
| width: 120px; | |
| height: 120px; | |
| object-fit: cover; | |
| border-radius: 14px; | |
| border: 1px solid rgba(0,0,0,0.08); | |
| flex-shrink: 0; | |
| }} | |
| .upload-meta h4 {{ | |
| margin: 0 0 0.35rem 0; | |
| font-size: 1.05rem; | |
| color: #1D1D1F; | |
| }} | |
| .upload-meta p {{ | |
| margin: 0; | |
| color: #636366; | |
| font-size: 0.9rem; | |
| }} | |
| .alert-user {{ | |
| background: #FFF9E6; | |
| border: 1px solid #F5D76E; | |
| border-radius: 14px; | |
| padding: 1.15rem 1.25rem; | |
| margin: 1.25rem 0 1.75rem 0; | |
| }} | |
| .alert-user h4 {{ | |
| margin: 0 0 0.5rem 0; | |
| font-size: 1.05rem; | |
| color: #7A5C00; | |
| }} | |
| .alert-user p {{ | |
| margin: 0; | |
| color: #5C4A00; | |
| font-size: 0.95rem; | |
| line-height: 1.45; | |
| }} | |
| .alert-error {{ | |
| background: #FFF0F0; | |
| border-color: #FFB4B4; | |
| }} | |
| .alert-error h4 {{ color: #8B1A1A; }} | |
| .alert-error p {{ color: #6B1515; }} | |
| .status-row {{ | |
| display: flex; | |
| flex-direction: column; | |
| gap: 0.65rem; | |
| font-size: 0.88rem; | |
| }} | |
| .status-item {{ | |
| display: flex; | |
| align-items: center; | |
| gap: 0.5rem; | |
| color: #1D1D1F; | |
| }} | |
| .status-dot {{ | |
| width: 9px; | |
| height: 9px; | |
| border-radius: 50%; | |
| flex-shrink: 0; | |
| }} | |
| .dot-green {{ background: #34C759; }} | |
| .dot-amber {{ background: #FF9500; }} | |
| .dot-red {{ background: #FF3B30; }} | |
| .dot-gray {{ background: #AEAEB2; }} | |
| .pill {{ | |
| display: inline-flex; | |
| align-items: center; | |
| padding: 0.45rem 0.9rem; | |
| border-radius: 99px; | |
| font-size: 0.88rem; | |
| font-weight: 600; | |
| margin-bottom: 1.25rem; | |
| }} | |
| .pill-ok {{ background: #E8F5E9; color: #248A3D; }} | |
| .pill-info {{ background: #E8F0FE; color: #0051D5; }} | |
| .pill-warn {{ background: #FFF9E6; color: #9A6B00; }} | |
| .breakdown-summary {{ | |
| display: grid; | |
| grid-template-columns: 1fr auto; | |
| gap: 0.35rem 1rem; | |
| font-size: 0.95rem; | |
| margin-bottom: 1.25rem; | |
| padding-bottom: 1rem; | |
| border-bottom: 1px solid rgba(0,0,0,0.06); | |
| }} | |
| .breakdown-summary .label {{ color: #636366; }} | |
| .breakdown-summary .val {{ font-weight: 700; color: #1D1D1F; text-align: right; }} | |
| .breakdown-assigned {{ | |
| font-size: 0.88rem; | |
| color: #8E8E93; | |
| margin: -0.5rem 0 1rem 0; | |
| }} | |
| .score-block {{ text-align: center; padding: 0.25rem 0; }} | |
| .score-heading {{ | |
| font-size: 0.8rem; | |
| font-weight: 700; | |
| text-transform: uppercase; | |
| letter-spacing: 0.05em; | |
| color: #8E8E93; | |
| margin: 0 0 0.75rem 0; | |
| }} | |
| .score-pulse-wrap {{ | |
| position: relative; | |
| width: 150px; | |
| height: 150px; | |
| margin: 0 auto 0.85rem; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| }} | |
| .score-pulse-wrap::before {{ | |
| content: ''; | |
| position: absolute; | |
| inset: -8px; | |
| border-radius: 50%; | |
| background: radial-gradient(circle, rgba(0, 122, 255, 0.3) 0%, transparent 70%); | |
| animation: pulse-glow 2.2s ease-in-out infinite; | |
| }} | |
| @keyframes pulse-glow {{ | |
| 0%, 100% {{ opacity: 0.4; transform: scale(0.96); }} | |
| 50% {{ opacity: 1; transform: scale(1.04); }} | |
| }} | |
| .score-ring {{ | |
| position: relative; | |
| z-index: 1; | |
| width: 150px; | |
| height: 150px; | |
| border-radius: 50%; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| background: rgba(255,255,255,0.9); | |
| border: 1px solid rgba(0,0,0,0.06); | |
| box-shadow: 0 4px 20px rgba(0,0,0,0.06); | |
| }} | |
| .score-ring .num {{ | |
| font-size: 2.5rem; | |
| font-weight: 700; | |
| line-height: 1; | |
| }} | |
| .score-ring .denom {{ | |
| font-size: 0.95rem; | |
| color: #8E8E93; | |
| font-weight: 600; | |
| }} | |
| .score-label-below {{ | |
| font-size: 1.2rem; | |
| font-weight: 700; | |
| margin: 0 0 0.5rem 0; | |
| }} | |
| .score-good {{ color: #248A3D; }} | |
| .score-mid {{ color: #B8860B; }} | |
| .score-bad {{ color: #C41E3A; }} | |
| .score-hint {{ | |
| font-size: 0.9rem; | |
| color: #636366; | |
| margin: 0 0 1rem 0; | |
| line-height: 1.4; | |
| }} | |
| .factor-chips {{ | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.4rem; | |
| justify-content: center; | |
| margin-bottom: 1rem; | |
| }} | |
| .factor-chip {{ | |
| font-size: 0.78rem; | |
| font-weight: 600; | |
| padding: 0.3rem 0.55rem; | |
| border-radius: 8px; | |
| background: rgba(0,0,0,0.05); | |
| color: #1D1D1F; | |
| }} | |
| .chip-good {{ background: #E8F5E9; color: #248A3D; }} | |
| .chip-mid {{ background: #FFF9E6; color: #9A6B00; }} | |
| .chip-low {{ background: #FFF0F0; color: #C41E3A; }} | |
| .nutrient-chips {{ | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 0.5rem; | |
| justify-content: center; | |
| margin-top: 0.75rem; | |
| }} | |
| .n-chip {{ | |
| font-size: 0.8rem; | |
| font-weight: 600; | |
| padding: 0.35rem 0.65rem; | |
| border-radius: 10px; | |
| background: rgba(0, 122, 255, 0.1); | |
| color: #0051D5; | |
| }} | |
| .nutrient-row {{ margin-bottom: 1.4rem; }} | |
| .nutrient-head {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: flex-end; | |
| margin-bottom: 0.5rem; | |
| }} | |
| .nutrient-head .name {{ font-size: 1rem; font-weight: 600; color: #1D1D1F; }} | |
| .nutrient-head .vals {{ font-size: 1rem; font-weight: 700; color: #1D1D1F; }} | |
| .nutrient-head .pct {{ font-size: 0.88rem; font-weight: 600; color: {ACCENT}; margin-left: 0.3rem; }} | |
| .nutrient-goal {{ font-size: 0.8rem; color: #8E8E93; margin-top: 0.3rem; }} | |
| .bar-track {{ | |
| height: 16px; | |
| background: rgba(0, 0, 0, 0.06); | |
| border-radius: 999px; | |
| overflow: hidden; | |
| }} | |
| .bar-fill {{ | |
| height: 100%; | |
| border-radius: 999px; | |
| background: linear-gradient(90deg, {ACCENT}, #0051D5); | |
| }} | |
| .plate-item {{ margin-bottom: 1.5rem; }} | |
| .plate-row-head {{ | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 0.75rem; | |
| margin-bottom: 0.35rem; | |
| }} | |
| .plate-row-name {{ | |
| font-size: 1.05rem; | |
| font-weight: 600; | |
| color: #1D1D1F; | |
| }} | |
| .plate-not-detected {{ | |
| font-size: 0.88rem; | |
| color: #8E8E93; | |
| font-style: italic; | |
| }} | |
| .plate-bar-track {{ | |
| height: 10px; | |
| background: rgba(0,0,0,0.06); | |
| border-radius: 999px; | |
| overflow: hidden; | |
| margin-top: 0.5rem; | |
| }} | |
| .plate-bar-fill {{ | |
| height: 100%; | |
| border-radius: 999px; | |
| transition: width 0.35s ease; | |
| background: linear-gradient(90deg, var(--accent), var(--accent-light)); | |
| }} | |
| .empty-hint {{ | |
| text-align: center; | |
| padding: 2rem 1.5rem; | |
| color: #636366; | |
| font-size: 1rem; | |
| }} | |
| [data-testid="stFileUploader"] {{ | |
| margin-top: -0.5rem; | |
| }} | |
| [data-testid="stFileUploader"] section {{ | |
| border: none !important; | |
| background: transparent !important; | |
| padding: 0 !important; | |
| min-height: 0 !important; | |
| }} | |
| [data-testid="stFileUploader"] section > div {{ | |
| padding: 0 !important; | |
| }} | |
| div.stButton > button[kind="primary"] {{ | |
| background: linear-gradient(135deg, #007AFF, #0051D5) !important; | |
| color: white !important; | |
| border: none !important; | |
| border-radius: 14px !important; | |
| padding: 0.7rem 1.5rem !important; | |
| font-weight: 600 !important; | |
| font-size: 1rem !important; | |
| }} | |
| div.stButton > button:disabled {{ | |
| opacity: 0.55 !important; | |
| }} | |
| [data-testid="stImage"] img {{ | |
| border-radius: 16px !important; | |
| }} | |
| """ | |
| def ensure_weights() -> None: | |
| """Download YOLO weights from the Hugging Face Hub if they're not already | |
| at the path the pipeline expects. Uses DEFAULT_WEIGHTS, so the file lands | |
| exactly where the existing check looks for it.""" | |
| if DEFAULT_WEIGHTS.exists(): | |
| return | |
| repo_id = os.environ.get("WEIGHTS_REPO_ID") | |
| if not repo_id: | |
| return # no repo configured; the normal "not found" error will show | |
| from huggingface_hub import hf_hub_download | |
| downloaded = hf_hub_download( | |
| repo_id=repo_id, | |
| filename=os.environ.get("WEIGHTS_FILENAME", "best.pt"), | |
| ) | |
| DEFAULT_WEIGHTS.parent.mkdir(parents=True, exist_ok=True) | |
| shutil.copy(downloaded, DEFAULT_WEIGHTS) | |
| def _html(fragment: str) -> str: | |
| return textwrap.dedent(fragment).strip() | |
| def md(html_content: str) -> None: | |
| st.markdown(_html(html_content), unsafe_allow_html=True) | |
| def _init_wizard_state() -> None: | |
| defaults: dict[str, Any] = { | |
| "wizard_step": 1, | |
| "yolo_conf": 0.05, | |
| "yolo_imgsz": 512, | |
| "main_plate_grams": 500, | |
| "portion_preset": "Medium (500g)", | |
| "analysis_status": "idle", | |
| "manual_mode": False, | |
| "gemini_runtime_error": None, | |
| "last_analysis_error": None, | |
| } | |
| for key, val in defaults.items(): | |
| st.session_state.setdefault(key, val) | |
| def go_to_step(step: int) -> None: | |
| st.session_state.wizard_step = max(1, min(TOTAL_WIZARD_STEPS, step)) | |
| st.rerun() | |
| def reset_wizard() -> None: | |
| for key in ( | |
| "upload_bytes", | |
| "upload_name", | |
| "analysis_result", | |
| "image_bytes", | |
| "grams_by_class", | |
| "last_analysis_error", | |
| "gemini_runtime_error", | |
| ): | |
| st.session_state.pop(key, None) | |
| st.session_state.wizard_step = 1 | |
| st.session_state.analysis_status = "idle" | |
| st.session_state.manual_mode = False | |
| st.rerun() | |
| def render_wizard_progress(current: int) -> None: | |
| pct = current / TOTAL_WIZARD_STEPS | |
| st.progress(pct, text=f"Step {current} of {TOTAL_WIZARD_STEPS}") | |
| dots = [] | |
| for i in range(1, TOTAL_WIZARD_STEPS + 1): | |
| if i < current: | |
| cls = "wizard-dot done" | |
| elif i == current: | |
| cls = "wizard-dot active" | |
| else: | |
| cls = "wizard-dot" | |
| dots.append(f'<span class="{cls}" title="{html.escape(WIZARD_STEP_NAMES[i - 1])}"></span>') | |
| names = " · ".join( | |
| f'<span style="color:{"#007AFF" if i + 1 == current else "#8E8E93"};font-weight:{"700" if i + 1 == current else "500"};">' | |
| f"{html.escape(name)}</span>" | |
| for i, name in enumerate(WIZARD_STEP_NAMES) | |
| ) | |
| md( | |
| f""" | |
| <div class="wizard-dots">{"".join(dots)}</div> | |
| <p class="wizard-progress-label">{names}</p> | |
| """ | |
| ) | |
| def render_wizard_header(title: str, subtitle: str = "") -> None: | |
| sub = f'<p class="wizard-subtitle">{html.escape(subtitle)}</p>' if subtitle else "" | |
| md( | |
| f""" | |
| <div class="wizard-page"> | |
| <h1 class="wizard-title">{html.escape(title)}</h1> | |
| {sub} | |
| </div> | |
| """ | |
| ) | |
| def wizard_nav( | |
| *, | |
| show_back: bool = True, | |
| show_next: bool = True, | |
| next_label: str = "Next →", | |
| next_disabled: bool = False, | |
| next_key: str = "wizard_next", | |
| back_key: str = "wizard_back", | |
| center_extra: Any = None, | |
| ) -> bool: | |
| """Render Back / optional center / Next. Returns True if Next was clicked.""" | |
| c_back, c_mid, c_next = st.columns([1, 2, 1]) | |
| with c_back: | |
| if show_back and st.button("← Back", use_container_width=True, key=back_key): | |
| go_to_step(int(st.session_state.wizard_step) - 1) | |
| with c_mid: | |
| if center_extra is not None: | |
| center_extra() | |
| with c_next: | |
| if show_next: | |
| clicked = st.button( | |
| next_label, | |
| type="primary", | |
| use_container_width=True, | |
| disabled=next_disabled, | |
| key=next_key, | |
| ) | |
| return bool(clicked) | |
| return False | |
| def load_api_keys_env() -> None: | |
| env_file = PROJECT_ROOT / "api_keys.env" | |
| if not env_file.exists(): | |
| return | |
| for line in env_file.read_text(encoding="utf-8").splitlines(): | |
| line = line.strip() | |
| if not line or line.startswith("#") or "=" not in line: | |
| continue | |
| key, _, value = line.partition("=") | |
| key = key.removeprefix("export ").strip() | |
| value = value.strip().strip("'").strip('"') | |
| os.environ.setdefault(key, value) | |
| def configure_ssl() -> None: | |
| if os.environ.get("SSL_CERT_FILE"): | |
| return | |
| try: | |
| import certifi | |
| os.environ["SSL_CERT_FILE"] = certifi.where() | |
| except ImportError: | |
| pass | |
| def inject_theme() -> None: | |
| md(f"<style>{THEME_CSS}</style>") | |
| def get_yolo_model(): | |
| return load_yolo_model() | |
| def _meal_score(totals: dict[str, float]) -> tuple[int, str, str]: | |
| weights = {"kcal": 0.25, "protein": 0.3, "fat": 0.2, "carbs": 0.25} | |
| score = 0.0 | |
| for key, weight in weights.items(): | |
| ref = float(NUTRIENT_GOALS[key]["goal"]) | |
| val = float(totals.get(key, 0)) | |
| inverse = bool(NUTRIENT_GOALS[key].get("inverse")) | |
| ratio = val / ref if ref else 0 | |
| if inverse: | |
| part = max(0.0, 100.0 - max(0.0, ratio - 0.5) * 80) | |
| else: | |
| part = max(0.0, 100.0 - abs(ratio - 0.75) * 90) | |
| score += part * weight | |
| score_int = int(max(0, min(100, round(score)))) | |
| if score_int >= 70: | |
| label, css = "Excellent balance", "score-good" | |
| elif score_int >= 45: | |
| label, css = "Fair balance", "score-mid" | |
| else: | |
| label, css = "Poor balance", "score-bad" | |
| return score_int, label, css | |
| def _nutrient_rating(key: str, value: float) -> tuple[str, str]: | |
| ref = float(NUTRIENT_GOALS[key]["goal"]) | |
| if ref <= 0: | |
| return "—", "chip-mid" | |
| ratio = value / ref | |
| inverse = bool(NUTRIENT_GOALS[key].get("inverse")) | |
| if inverse: | |
| if ratio <= 0.85: | |
| return "Good", "chip-good" | |
| if ratio <= 1.15: | |
| return "Moderate", "chip-mid" | |
| return "High", "chip-low" | |
| if ratio >= 0.65 and ratio <= 1.1: | |
| return "Good", "chip-good" | |
| if ratio >= 0.35: | |
| return "Moderate" if ratio < 0.65 else "High", "chip-mid" | |
| return "Low", "chip-low" | |
| def _score_factors(totals: dict[str, float]) -> list[tuple[str, str, str]]: | |
| labels = { | |
| "kcal": "Calories", | |
| "protein": "Protein", | |
| "fat": "Fat", | |
| "carbs": "Carbs", | |
| } | |
| return [ | |
| (labels[key], *_nutrient_rating(key, float(totals.get(key, 0)))) | |
| for key in ("protein", "carbs", "fat", "kcal") | |
| ] | |
| def _score_hint(totals: dict[str, float], grams_by_class: dict[str, float]) -> str: | |
| veg = float(grams_by_class.get("vegetables", 0)) | |
| protein = float(totals.get("protein", 0)) | |
| carbs = float(totals.get("carbs", 0)) | |
| parts: list[str] = [] | |
| if protein >= float(NUTRIENT_GOALS["protein"]["goal"]) * 0.7: | |
| parts.append("solid protein") | |
| else: | |
| parts.append("lower protein") | |
| if carbs < float(NUTRIENT_GOALS["carbs"]["goal"]) * 0.4: | |
| parts.append("fewer carbs detected") | |
| elif carbs > float(NUTRIENT_GOALS["carbs"]["goal"]) * 1.2: | |
| parts.append("higher carbs") | |
| if veg < 30: | |
| parts.append("limited vegetables") | |
| elif veg >= 80: | |
| parts.append("good vegetable portion") | |
| if not parts: | |
| return "Review the estimated breakdown below to improve accuracy." | |
| return f"{' · '.join(parts).capitalize()}. Adjust portions below if needed." | |
| def _macro_table_from_result(result: dict[str, Any]) -> dict[str, dict[str, float]]: | |
| table: dict[str, dict[str, float]] = {} | |
| for row in result.get("macros_per_100g", []): | |
| name = str(row["class_name"]) | |
| table[name] = { | |
| "kcal": float(row.get("kcal") or 0), | |
| "protein": float(row.get("protein") or 0), | |
| "fat": float(row.get("fat") or 0), | |
| "carbs": float(row.get("carbs") or 0), | |
| } | |
| return table | |
| def _initial_grams_by_class(result: dict[str, Any], total_plate_grams: float) -> dict[str, float]: | |
| segments = result["segments"]["segments"] | |
| portions = yaml.safe_load(DEFAULT_PORTIONS.read_text(encoding="utf-8")) | |
| portions = {**portions, "total_plate_grams": float(total_plate_grams)} | |
| grams = estimate_grams(segments, portions) | |
| for cls in CLASS_ORDER: | |
| grams.setdefault(cls, 0.0) | |
| return grams | |
| def _recalculate_macros( | |
| grams_by_class: dict[str, float], | |
| macro_table: dict[str, dict[str, float]], | |
| ) -> tuple[list[dict[str, Any]], dict[str, float]]: | |
| active = {k: v for k, v in grams_by_class.items() if v > 0 and k in macro_table} | |
| if not active: | |
| return [], {"grams": 0.0, "kcal": 0.0, "protein": 0.0, "fat": 0.0, "carbs": 0.0} | |
| items, totals = estimate_macros(active, macro_table) | |
| return items, totals | |
| def _class_detected(segment_by_class: dict[str, Any], cls: str, grams: float) -> bool: | |
| seg = segment_by_class.get(cls) | |
| if seg and float(seg.get("area_fraction") or 0) > 0.01: | |
| return True | |
| return grams > 0 | |
| def _render_nutrient_bar(key: str, value: float) -> str: | |
| meta = NUTRIENT_GOALS[key] | |
| goal = float(meta["goal"]) | |
| unit = str(meta["unit"]) | |
| label = str(meta["label"]) | |
| icon = str(meta["icon"]) | |
| pct = min(100.0, round((value / goal) * 100)) if goal else 0 | |
| bar_pct = min(100.0, (value / goal) * 100) if goal else 0 | |
| return _html( | |
| f""" | |
| <div class="nutrient-row"> | |
| <div class="nutrient-head"> | |
| <span class="name">{icon} {html.escape(label)}</span> | |
| <span class="vals"> | |
| {round(value):,} {html.escape(unit)} | |
| <span class="pct">{pct:.0f}%</span> | |
| </span> | |
| </div> | |
| <div class="bar-track"> | |
| <div class="bar-fill" style="width:{bar_pct:.0f}%"></div> | |
| </div> | |
| <div class="nutrient-goal">of {goal:,.0f} {html.escape(unit)} goal</div> | |
| </div> | |
| """ | |
| ) | |
| def _gemini_status_label( | |
| *, | |
| key_configured: bool, | |
| use_gemini: bool, | |
| runtime_error: str | None, | |
| manual_mode: bool, | |
| ) -> tuple[str, str]: | |
| if not use_gemini: | |
| return "Disabled", "dot-gray" | |
| if not key_configured: | |
| return "Not configured", "dot-gray" | |
| if runtime_error or manual_mode: | |
| return "Error", "dot-red" | |
| return "Connected", "dot-green" | |
| def render_sidebar_settings() -> tuple[bool, bool, float, int]: | |
| st.markdown("### Scan settings") | |
| use_gemini = st.toggle("Gemini food ID", value=True, help="Identify foods with Gemini Vision") | |
| use_usda = st.toggle("USDA lookup", value=True, help="Fetch nutrition from USDA FoodData Central") | |
| st.divider() | |
| render_api_status(use_gemini=use_gemini, use_usda=use_usda) | |
| st.divider() | |
| with st.expander("Advanced settings", expanded=False): | |
| conf = st.slider( | |
| "Detection confidence", | |
| min_value=0.01, | |
| max_value=0.5, | |
| value=float(st.session_state.get("yolo_conf", 0.05)), | |
| step=0.01, | |
| ) | |
| imgsz_options = [320, 512, 640] | |
| saved_imgsz = int(st.session_state.get("yolo_imgsz", 512)) | |
| imgsz = st.selectbox( | |
| "Image size (px)", | |
| options=imgsz_options, | |
| index=imgsz_options.index(saved_imgsz) if saved_imgsz in imgsz_options else 1, | |
| ) | |
| st.session_state["yolo_conf"] = conf | |
| st.session_state["yolo_imgsz"] = imgsz | |
| return use_gemini, use_usda, conf, int(imgsz) | |
| def render_api_status(*, use_gemini: bool, use_usda: bool) -> None: | |
| st.markdown("**API status**") | |
| gemini_key = bool(os.getenv("GEMINI_API_KEY")) | |
| usda_key = bool(os.getenv("FDC_API_KEY")) | |
| runtime_err = st.session_state.get("gemini_runtime_error") | |
| manual = st.session_state.get("manual_mode", False) | |
| g_label, g_dot = _gemini_status_label( | |
| key_configured=gemini_key, | |
| use_gemini=use_gemini, | |
| runtime_error=runtime_err, | |
| manual_mode=manual, | |
| ) | |
| if not use_usda: | |
| u_label, u_dot = "Disabled", "dot-gray" | |
| elif usda_key: | |
| u_label, u_dot = "Connected", "dot-green" | |
| else: | |
| u_label, u_dot = "Not configured", "dot-red" | |
| md( | |
| f""" | |
| <div class="status-row" role="status"> | |
| <div class="status-item"> | |
| <span class="status-dot {g_dot}" aria-hidden="true"></span> | |
| <span><strong>Gemini Vision:</strong> {html.escape(g_label)}</span> | |
| </div> | |
| <div class="status-item"> | |
| <span class="status-dot {u_dot}" aria-hidden="true"></span> | |
| <span><strong>USDA Database:</strong> {html.escape(u_label)}</span> | |
| </div> | |
| </div> | |
| """ | |
| ) | |
| def render_analysis_status_alert(result: dict[str, Any] | None) -> None: | |
| if result is None: | |
| return | |
| gemini_err = result.get("gemini_error") | |
| pipeline_err = st.session_state.get("last_analysis_error") | |
| if pipeline_err and not result.get("segments"): | |
| md( | |
| f""" | |
| <div class="alert-user alert-error" role="alert"> | |
| <h4>Automatic analysis failed</h4> | |
| <p>You can retry or enter the plate breakdown manually below.</p> | |
| </div> | |
| """ | |
| ) | |
| with st.expander("Technical details"): | |
| st.code(str(pipeline_err)) | |
| return | |
| if gemini_err: | |
| st.session_state.gemini_runtime_error = str(gemini_err) | |
| st.session_state.manual_mode = True | |
| md( | |
| """ | |
| <div class="alert-user" role="alert"> | |
| <h4>Automatic meal recognition unavailable</h4> | |
| <p>We could not analyze the image automatically. You can retry or adjust | |
| the estimated plate breakdown manually below.</p> | |
| </div> | |
| """ | |
| ) | |
| with st.expander("Technical details"): | |
| st.code(str(gemini_err)) | |
| elif st.session_state.get("manual_mode"): | |
| md( | |
| """ | |
| <div class="alert-user" role="status"> | |
| <h4>Manual breakdown mode</h4> | |
| <p>Automatic detection had issues earlier. Adjust portions below — nutrition | |
| updates as you edit.</p> | |
| </div> | |
| """ | |
| ) | |
| def render_meal_score_card( | |
| score: int, | |
| label: str, | |
| css_class: str, | |
| totals: dict[str, float], | |
| grams_by_class: dict[str, float], | |
| ) -> None: | |
| factors = _score_factors(totals) | |
| hint = _score_hint(totals, grams_by_class) | |
| chips = "".join( | |
| f'<span class="factor-chip {css}">{html.escape(name)}: {html.escape(rating)}</span>' | |
| for name, rating, css in factors | |
| ) | |
| n_chips = "".join( | |
| f'<span class="n-chip">{html.escape(str(NUTRIENT_GOALS[k]["label"]))}: ' | |
| f'{round(float(totals.get(k, 0))):,}</span>' | |
| for k in ("kcal", "protein", "carbs", "fat") | |
| ) | |
| md( | |
| f""" | |
| <div class="glass-card"> | |
| <p class="score-heading">Meal balance score</p> | |
| <div class="score-block"> | |
| <div class="score-pulse-wrap"> | |
| <div class="score-ring {css_class}"> | |
| <span class="num">{score}</span> | |
| <span class="denom">/ 100</span> | |
| </div> | |
| </div> | |
| <p class="score-label-below {css_class}">{html.escape(label)}</p> | |
| <p class="score-hint">{html.escape(hint)}</p> | |
| <div class="factor-chips">{chips}</div> | |
| <p style="text-align:center;color:#8E8E93;font-size:0.92rem;margin:0;"> | |
| Total portion: <strong style="color:#1D1D1F">{totals["grams"]:.0f} g</strong> | |
| </p> | |
| <div class="nutrient-chips">{n_chips}</div> | |
| </div> | |
| </div> | |
| """ | |
| ) | |
| def render_plate_breakdown_editor( | |
| result: dict[str, Any], | |
| total_plate_grams: float, | |
| macro_table: dict[str, dict[str, float]], | |
| *, | |
| show_section_header: bool = True, | |
| ) -> tuple[list[dict[str, Any]], dict[str, float], dict[str, float]]: | |
| segments = result["segments"]["segments"] | |
| segment_by_class = {str(s["class_name"]): s for s in segments} | |
| if "grams_by_class" not in st.session_state: | |
| st.session_state.grams_by_class = _initial_grams_by_class(result, total_plate_grams) | |
| gemini_by_class: dict[str, str] = {} | |
| if result.get("gemini_analysis"): | |
| for comp in result["gemini_analysis"].get("components", []): | |
| gemini_by_class[str(comp.get("class_name", ""))] = str( | |
| comp.get("likely_food") or comp.get("fdc_query") or "" | |
| ) | |
| if show_section_header: | |
| md('<p class="section-header">Estimated plate breakdown</p>') | |
| action_cols = st.columns([1, 1, 2]) | |
| with action_cols[0]: | |
| reset = st.button("Reset breakdown", use_container_width=True, key="btn_reset_breakdown") | |
| with action_cols[1]: | |
| st.caption("Edit detected foods below") | |
| if reset: | |
| st.session_state.grams_by_class = _initial_grams_by_class(result, total_plate_grams) | |
| st.rerun() | |
| grams_by_class: dict[str, float] = {} | |
| for cls in CLASS_ORDER: | |
| grams_by_class[cls] = float(st.session_state.grams_by_class.get(cls, 0)) | |
| assigned = sum(grams_by_class.values()) | |
| other_g = max(0.0, float(total_plate_grams) - assigned) | |
| summary_rows = "".join( | |
| f'<span class="label">{CLASS_META[c]["icon"]} {html.escape(CLASS_META[c]["label"])}</span>' | |
| f'<span class="val">{grams_by_class[c]:.0f} g</span>' | |
| for c in CLASS_ORDER | |
| ) | |
| if other_g > 0.5: | |
| summary_rows += ( | |
| f'<span class="label">Other / unassigned</span>' | |
| f'<span class="val">{other_g:.0f} g</span>' | |
| ) | |
| md( | |
| f""" | |
| <div class="glass-card"> | |
| <p style="font-weight:600;color:#1D1D1F;margin:0 0 0.75rem;">Detected foods</p> | |
| <div class="breakdown-summary">{summary_rows}</div> | |
| <p class="breakdown-assigned"> | |
| Assigned: <strong>{assigned:.0f} g</strong> / {total_plate_grams:.0f} g | |
| · Remaining: <strong>{other_g:.0f} g</strong> | |
| </p> | |
| </div> | |
| """ | |
| ) | |
| md('<div class="glass-card">') | |
| for cls in CLASS_ORDER: | |
| meta = CLASS_META[cls] | |
| detected = _class_detected(segment_by_class, cls, grams_by_class[cls]) | |
| default_g = int(round(float(st.session_state.grams_by_class.get(cls, 0)))) | |
| food_label = gemini_by_class.get(cls) or meta["label"] | |
| head_cols = st.columns([2, 1, 1]) | |
| with head_cols[0]: | |
| st.markdown(f"**{meta['icon']} {food_label}**") | |
| if not detected and default_g == 0: | |
| st.caption("Not detected") | |
| with head_cols[1]: | |
| grams_val = st.number_input( | |
| f"{meta['label']} grams", | |
| min_value=0, | |
| max_value=CLASS_GRAMS_MAX, | |
| value=default_g, | |
| step=5, | |
| key=f"grams_num_{cls}", | |
| label_visibility="collapsed", | |
| ) | |
| with head_cols[2]: | |
| st.markdown("<span style='color:#8E8E93;font-size:0.85rem'>g</span>", unsafe_allow_html=True) | |
| grams_by_class[cls] = float( | |
| st.slider( | |
| f"{meta['label']} slider", | |
| min_value=0, | |
| max_value=CLASS_GRAMS_MAX, | |
| value=int(grams_val), | |
| step=5, | |
| key=f"grams_slider_{cls}", | |
| label_visibility="collapsed", | |
| ) | |
| ) | |
| if not detected and grams_by_class[cls] == 0: | |
| if st.button(f"Add {meta['label'].lower()}", key=f"btn_add_{cls}"): | |
| st.session_state.grams_by_class[cls] = min(75, int(total_plate_grams * 0.15)) | |
| st.rerun() | |
| st.markdown("<div style='height:0.25rem'></div>", unsafe_allow_html=True) | |
| md("</div>") | |
| total_slider_g = sum(grams_by_class.values()) or 1.0 | |
| bar_parts = ['<div class="glass-card" style="margin-top:-0.5rem;padding-top:0.5rem;">'] | |
| for cls in CLASS_ORDER: | |
| meta = CLASS_META[cls] | |
| share_pct = (grams_by_class[cls] / total_slider_g) * 100 | |
| accent = meta["accent"] | |
| bar_parts.append( | |
| f'<p style="font-size:0.88rem;color:#636366;margin:0 0 0.25rem;">' | |
| f'{meta["icon"]} {html.escape(meta["label"])} · ' | |
| f'<strong>{share_pct:.0f}%</strong> · {grams_by_class[cls]:.0f} g</p>' | |
| f'<div class="plate-bar-track"><div class="plate-bar-fill" ' | |
| f'style="width:{min(share_pct, 100):.1f}%;--accent:{accent};--accent-light:{accent}99;">' | |
| f"</div></div><div style='height:0.85rem'></div>" | |
| ) | |
| bar_parts.append("</div>") | |
| st.markdown("".join(bar_parts), unsafe_allow_html=True) | |
| st.session_state.grams_by_class = grams_by_class | |
| items, totals = _recalculate_macros(grams_by_class, macro_table) | |
| return items, totals, grams_by_class | |
| def _sync_grams_and_totals( | |
| result: dict[str, Any], | |
| total_plate_grams: float, | |
| macro_table: dict[str, dict[str, float]], | |
| ) -> tuple[list[dict[str, Any]], dict[str, float], dict[str, float]]: | |
| if "grams_by_class" not in st.session_state: | |
| st.session_state.grams_by_class = _initial_grams_by_class(result, total_plate_grams) | |
| grams = {k: float(st.session_state.grams_by_class.get(k, 0)) for k in CLASS_ORDER} | |
| items, totals = _recalculate_macros(grams, macro_table) | |
| return items, totals, grams | |
| def wizard_step_1_upload() -> None: | |
| render_wizard_progress(1) | |
| render_wizard_header( | |
| "Upload your meal photo", | |
| "Drag and drop a top-down plate photo, or click to browse.", | |
| ) | |
| if not st.session_state.get("upload_bytes"): | |
| md( | |
| """ | |
| <div class="wizard-upload-zone wizard-center"> | |
| <h3>📷 Upload meal photo</h3> | |
| <p>Drag and drop an image here, or click to browse</p> | |
| <p class="hint" style="color:#8E8E93;font-size:0.88rem;margin-top:0.75rem;"> | |
| Supports JPG, PNG, WEBP, and HEIC | |
| </p> | |
| </div> | |
| """ | |
| ) | |
| uploaded = st.file_uploader( | |
| "Upload meal photo", | |
| type=["jpg", "jpeg", "png", "webp", "heic", "heif", "bmp", "tiff", "tif", "gif"], | |
| label_visibility="collapsed", | |
| key="wizard_file_uploader", | |
| ) | |
| if uploaded is not None: | |
| st.session_state.upload_bytes = uploaded.getvalue() | |
| st.session_state.upload_name = uploaded.name | |
| # --- Or load an image from a folder on this PC (local app only) --- | |
| with st.expander("📁 …or open a folder of photos"): | |
| folder = st.text_input( | |
| "Folder path", | |
| key="folder_path_input", | |
| placeholder=r"D:\my_meal_photos", | |
| ) | |
| if folder: | |
| folder_path = Path(folder) | |
| if not folder_path.is_dir(): | |
| st.warning("That folder doesn't exist (or isn't a folder).") | |
| else: | |
| exts = (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif", ".gif") | |
| images = sorted( | |
| p for p in folder_path.iterdir() if p.suffix.lower() in exts | |
| ) | |
| if not images: | |
| st.info("No image files found in that folder.") | |
| else: | |
| chosen = st.selectbox( | |
| f"{len(images)} image(s) found — pick one", | |
| images, | |
| format_func=lambda p: p.name, | |
| key="folder_image_select", | |
| ) | |
| if st.button("Use this image", key="folder_use_btn"): | |
| st.session_state.upload_bytes = chosen.read_bytes() | |
| st.session_state.upload_name = chosen.name | |
| st.rerun() | |
| has_image = bool(st.session_state.get("upload_bytes")) | |
| if has_image: | |
| name = str(st.session_state.get("upload_name", "meal.jpg")) | |
| img = Image.open(io.BytesIO(st.session_state.upload_bytes)) | |
| img.thumbnail((640, 640)) | |
| buf = io.BytesIO() | |
| img.save(buf, format="JPEG", quality=90) | |
| b64 = base64.b64encode(buf.getvalue()).decode() | |
| md( | |
| f""" | |
| <div class="wizard-preview"> | |
| <img src="data:image/jpeg;base64,{b64}" alt="Meal preview" /> | |
| <p style="margin-top:1rem;color:#636366;"> | |
| <strong style="color:#1D1D1F">{html.escape(name)}</strong> | |
| </p> | |
| </div> | |
| """ | |
| ) | |
| st.markdown("<div style='height:2rem'></div>", unsafe_allow_html=True) | |
| if wizard_nav( | |
| show_back=False, | |
| next_label="Next →", | |
| next_disabled=not has_image, | |
| next_key="w1_next", | |
| ): | |
| go_to_step(2) | |
| def wizard_step_2_plate() -> None: | |
| render_wizard_progress(2) | |
| render_wizard_header( | |
| "Plate settings", | |
| "Set the total weight of food on your plate for portion estimates.", | |
| ) | |
| weight = int(st.session_state.get("main_plate_grams", 500)) | |
| md(f'<p class="wizard-weight-display">{weight}<span style="font-size:1.5rem;color:#636366"> g</span></p>') | |
| st.markdown("<p style='text-align:center;color:#636366;margin-bottom:0.75rem;'>Portion size</p>", unsafe_allow_html=True) | |
| p1, p2, p3 = st.columns(3) | |
| presets = (("Small", 300), ("Medium", 500), ("Large", 750)) | |
| for col, (label, grams) in zip((p1, p2, p3), presets): | |
| with col: | |
| if st.button(f"{label}\n{grams} g", use_container_width=True, key=f"preset_{grams}"): | |
| st.session_state.main_plate_grams = grams | |
| st.session_state.portion_preset = f"{label} ({grams}g)" | |
| st.rerun() | |
| weight = st.slider( | |
| "Total plate weight", | |
| min_value=PLATE_WEIGHT_MIN, | |
| max_value=PLATE_WEIGHT_MAX, | |
| value=int(st.session_state.get("main_plate_grams", 500)), | |
| step=25, | |
| key="wizard_plate_slider", | |
| ) | |
| st.session_state.main_plate_grams = int(weight) | |
| st.markdown("<div style='height:2rem'></div>", unsafe_allow_html=True) | |
| if wizard_nav(show_back=True, next_label="Next →", next_key="w2_next"): | |
| go_to_step(3) | |
| def wizard_step_3_analyze( | |
| *, | |
| use_gemini: bool, | |
| use_usda: bool, | |
| yolo_conf: float, | |
| yolo_imgsz: int, | |
| ) -> None: | |
| render_wizard_progress(3) | |
| render_wizard_header( | |
| "Analyze your meal", | |
| "We'll segment your plate and estimate nutrition.", | |
| ) | |
| if not st.session_state.get("upload_bytes"): | |
| st.warning("Please upload a photo first.") | |
| wizard_nav(show_back=True, show_next=False) | |
| return | |
| plate_grams = float(st.session_state.get("main_plate_grams", 500)) | |
| st.caption(f"Plate weight: **{plate_grams:.0f} g** · Ready to analyze") | |
| if st.session_state.get("last_analysis_error"): | |
| st.error("Analysis failed. Try again or go back to adjust settings.") | |
| with st.expander("Technical details"): | |
| st.code(st.session_state.last_analysis_error) | |
| md('<div class="analyze-hero wizard-center">') | |
| analyze_clicked = st.button( | |
| "Analyze my meal", | |
| type="primary", | |
| use_container_width=True, | |
| key="wizard_analyze_btn", | |
| ) | |
| if analyze_clicked: | |
| with st.spinner("Analyzing image… Identifying foods and estimating portions."): | |
| ok = run_analysis( | |
| st.session_state.upload_bytes, | |
| str(st.session_state.get("upload_name", "meal.jpg")), | |
| use_gemini=use_gemini, | |
| use_usda=use_usda, | |
| plate_grams=plate_grams, | |
| yolo_conf=yolo_conf, | |
| yolo_imgsz=yolo_imgsz, | |
| ) | |
| if ok: | |
| go_to_step(4) | |
| else: | |
| st.rerun() | |
| md("</div>") | |
| st.markdown("<div style='height:1.5rem'></div>", unsafe_allow_html=True) | |
| wizard_nav(show_back=True, show_next=False, back_key="w3_back") | |
| def wizard_step_4_results( | |
| result: dict[str, Any], | |
| image_bytes: bytes, | |
| total_plate_grams: float, | |
| ) -> None: | |
| render_wizard_progress(4) | |
| render_wizard_header( | |
| "Your results", | |
| "Review nutrition and adjust the plate breakdown if needed.", | |
| ) | |
| macro_table = _macro_table_from_result(result) | |
| render_analysis_status_alert(result) | |
| if not result.get("gemini_error") and not st.session_state.get("last_analysis_error"): | |
| md('<div class="pill pill-ok">✓ Meal analyzed successfully</div>') | |
| if result.get("gemini_analysis"): | |
| summary = str(result["gemini_analysis"].get("meal_summary", ""))[:80] | |
| if summary: | |
| md( | |
| f'<p style="text-align:center;font-size:1.05rem;font-weight:600;' | |
| f'color:#1D1D1F;margin:0 0 1.25rem;">{html.escape(summary)}</p>' | |
| ) | |
| score_top = st.container() | |
| plate_section = st.container() | |
| with plate_section: | |
| items, totals, grams_by_class = render_plate_breakdown_editor( | |
| result, | |
| total_plate_grams, | |
| macro_table, | |
| show_section_header=True, | |
| ) | |
| score, score_label, score_css = _meal_score(totals) | |
| with score_top: | |
| col_score, col_bars = st.columns([1, 1.35]) | |
| with col_score: | |
| render_meal_score_card(score, score_label, score_css, totals, grams_by_class) | |
| with col_bars: | |
| md('<p class="section-header" style="margin-top:0;">Nutrients</p>') | |
| nutrients_html = '<div class="glass-card">' | |
| for key in ("kcal", "protein", "fat", "carbs"): | |
| nutrients_html += _render_nutrient_bar(key, float(totals.get(key, 0))) | |
| nutrients_html += "</div>" | |
| st.markdown(nutrients_html, unsafe_allow_html=True) | |
| st.markdown("<div style='height:1.75rem'></div>", unsafe_allow_html=True) | |
| st.markdown("<div style='height:1.75rem'></div>", unsafe_allow_html=True) | |
| md('<p class="section-header">Meal photo</p>') | |
| view = st.radio( | |
| "Photo view", | |
| options=["Original", "Segmentation"], | |
| horizontal=True, | |
| label_visibility="collapsed", | |
| key="wizard_photo_view", | |
| ) | |
| if view == "Original": | |
| st.image(Image.open(io.BytesIO(image_bytes)), use_container_width=True) | |
| else: | |
| overlay_rgb = cv2.cvtColor(result["overlay_bgr"], cv2.COLOR_BGR2RGB) | |
| st.image(overlay_rgb, use_container_width=True) | |
| st.markdown("<div style='height:1.5rem'></div>", unsafe_allow_html=True) | |
| nav1, nav2, nav3 = st.columns(3) | |
| with nav1: | |
| if st.button("↺ Start over", use_container_width=True, key="w4_start_over"): | |
| reset_wizard() | |
| with nav2: | |
| if st.button("View nutrition details →", use_container_width=True, key="w4_details"): | |
| go_to_step(5) | |
| with nav3: | |
| if st.button("← Back", use_container_width=True, key="w4_back"): | |
| go_to_step(3) | |
| def wizard_step_5_details(result: dict[str, Any], total_plate_grams: float) -> None: | |
| render_wizard_progress(5) | |
| render_wizard_header( | |
| "Nutrition details", | |
| "Per-class breakdown and per 100 g reference values.", | |
| ) | |
| macro_table = _macro_table_from_result(result) | |
| items, totals, _ = _sync_grams_and_totals(result, total_plate_grams, macro_table) | |
| st.markdown("#### Per-class nutrition") | |
| if items: | |
| st.dataframe(pd.DataFrame(items), hide_index=True, use_container_width=True) | |
| else: | |
| st.info("Assign food weights on the results step to see per-class nutrition.") | |
| st.markdown("#### Per 100 g reference") | |
| st.dataframe(pd.DataFrame(result["macros_per_100g"]), hide_index=True, use_container_width=True) | |
| st.markdown("<div style='height:2rem'></div>", unsafe_allow_html=True) | |
| if wizard_nav(show_back=True, show_next=False, back_key="w5_back"): | |
| go_to_step(4) | |
| def run_analysis( | |
| image_bytes: bytes, | |
| filename: str, | |
| *, | |
| use_gemini: bool, | |
| use_usda: bool, | |
| plate_grams: float, | |
| yolo_conf: float, | |
| yolo_imgsz: int, | |
| ) -> bool: | |
| st.session_state.analysis_status = "loading" | |
| st.session_state.last_analysis_error = None | |
| suffix = Path(filename).suffix or ".jpg" | |
| with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp: | |
| tmp.write(image_bytes) | |
| image_path = Path(tmp.name) | |
| try: | |
| analysis = run_meal_analysis( | |
| image_path, | |
| use_gemini=use_gemini, | |
| use_usda=use_usda, | |
| total_plate_grams=float(plate_grams), | |
| yolo_model=get_yolo_model(), | |
| yolo_conf=yolo_conf, | |
| yolo_imgsz=yolo_imgsz, | |
| ) | |
| st.session_state.analysis_result = analysis | |
| st.session_state.image_bytes = image_bytes | |
| st.session_state.analysis_status = "success" | |
| if analysis.get("gemini_error"): | |
| st.session_state.gemini_runtime_error = str(analysis["gemini_error"]) | |
| st.session_state.manual_mode = True | |
| else: | |
| st.session_state.gemini_runtime_error = None | |
| st.session_state.manual_mode = False | |
| st.session_state.pop("grams_by_class", None) | |
| return True | |
| except Exception as exc: | |
| st.session_state.analysis_status = "error" | |
| st.session_state.last_analysis_error = str(exc) | |
| st.set_page_config(page_title="Meal Scan", page_icon="🥗", layout="wide", initial_sidebar_state="expanded") | |
| return False | |
| finally: | |
| image_path.unlink(missing_ok=True) | |
| def main() -> None: | |
| st.set_page_config(page_title="Meal Scan", page_icon="🥗", layout="wide") | |
| inject_theme() | |
| load_api_keys_env() | |
| configure_ssl() | |
| ensure_weights() | |
| if not DEFAULT_WEIGHTS.exists() or get_yolo_model() is None: | |
| st.error("YOLO model weights not found.") | |
| st.stop() | |
| _init_wizard_state() | |
| with st.sidebar: | |
| use_gemini, use_usda, yolo_conf, yolo_imgsz = render_sidebar_settings() | |
| md( | |
| """ | |
| <div style="text-align:center;padding:0.25rem 0 0.5rem;"> | |
| <p style="font-size:0.95rem;font-weight:600;color:#8E8E93;margin:0; | |
| letter-spacing:0.04em;text-transform:uppercase;">Meal Scan</p> | |
| </div> | |
| """ | |
| ) | |
| step = int(st.session_state.get("wizard_step", 1)) | |
| if step == 1: | |
| wizard_step_1_upload() | |
| elif step == 2: | |
| wizard_step_2_plate() | |
| elif step == 3: | |
| wizard_step_3_analyze( | |
| use_gemini=use_gemini, | |
| use_usda=use_usda, | |
| yolo_conf=yolo_conf, | |
| yolo_imgsz=yolo_imgsz, | |
| ) | |
| elif step == 4: | |
| if "analysis_result" not in st.session_state: | |
| go_to_step(3) | |
| else: | |
| img = st.session_state.get("image_bytes") or st.session_state.get("upload_bytes") | |
| if img: | |
| wizard_step_4_results( | |
| st.session_state.analysis_result, | |
| img, | |
| float(st.session_state.get("main_plate_grams", 500)), | |
| ) | |
| else: | |
| go_to_step(1) | |
| elif step == 5: | |
| if "analysis_result" not in st.session_state: | |
| go_to_step(3) | |
| else: | |
| wizard_step_5_details( | |
| st.session_state.analysis_result, | |
| float(st.session_state.get("main_plate_grams", 500)), | |
| ) | |
| else: | |
| st.session_state.wizard_step = 1 | |
| st.rerun() | |
| main() | |