"""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'') names = " Β· ".join( f'' f"{html.escape(name)}" for i, name in enumerate(WIZARD_STEP_NAMES) ) md( f"""
{"".join(dots)}

{names}

""" ) def render_wizard_header(title: str, subtitle: str = "") -> None: sub = f'

{html.escape(subtitle)}

' if subtitle else "" md( f"""

{html.escape(title)}

{sub}
""" ) 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"") @st.cache_resource 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"""
{icon} {html.escape(label)} {round(value):,} {html.escape(unit)} {pct:.0f}%
of {goal:,.0f} {html.escape(unit)} goal
""" ) 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"""
Gemini Vision: {html.escape(g_label)}
USDA Database: {html.escape(u_label)}
""" ) 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""" """ ) 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( """ """ ) with st.expander("Technical details"): st.code(str(gemini_err)) elif st.session_state.get("manual_mode"): md( """

Manual breakdown mode

Automatic detection had issues earlier. Adjust portions below β€” nutrition updates as you edit.

""" ) 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'{html.escape(name)}: {html.escape(rating)}' for name, rating, css in factors ) n_chips = "".join( f'{html.escape(str(NUTRIENT_GOALS[k]["label"]))}: ' f'{round(float(totals.get(k, 0))):,}' for k in ("kcal", "protein", "carbs", "fat") ) md( f"""

Meal balance score

{score} / 100

{html.escape(label)}

{html.escape(hint)}

{chips}

Total portion: {totals["grams"]:.0f} g

{n_chips}
""" ) 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('

Estimated plate breakdown

') 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'{CLASS_META[c]["icon"]} {html.escape(CLASS_META[c]["label"])}' f'{grams_by_class[c]:.0f} g' for c in CLASS_ORDER ) if other_g > 0.5: summary_rows += ( f'Other / unassigned' f'{other_g:.0f} g' ) md( f"""

Detected foods

{summary_rows}

Assigned: {assigned:.0f} g / {total_plate_grams:.0f} g Β· Remaining: {other_g:.0f} g

""" ) md('
') 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("g", 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("
", unsafe_allow_html=True) md("
") total_slider_g = sum(grams_by_class.values()) or 1.0 bar_parts = ['
'] 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'

' f'{meta["icon"]} {html.escape(meta["label"])} Β· ' f'{share_pct:.0f}% Β· {grams_by_class[cls]:.0f} g

' f'
' f"
" ) bar_parts.append("
") 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( """

πŸ“· Upload meal photo

Drag and drop an image here, or click to browse

Supports JPG, PNG, WEBP, and HEIC

""" ) 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"""
Meal preview

{html.escape(name)}

""" ) st.markdown("
", 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'

{weight} g

') st.markdown("

Portion size

", 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("
", 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('
') 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("
") st.markdown("
", 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('
βœ“ Meal analyzed successfully
') if result.get("gemini_analysis"): summary = str(result["gemini_analysis"].get("meal_summary", ""))[:80] if summary: md( f'

{html.escape(summary)}

' ) 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('

Nutrients

') nutrients_html = '
' for key in ("kcal", "protein", "fat", "carbs"): nutrients_html += _render_nutrient_bar(key, float(totals.get(key, 0))) nutrients_html += "
" st.markdown(nutrients_html, unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) st.markdown("
", unsafe_allow_html=True) md('

Meal photo

') 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("
", 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("
", 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( """

Meal Scan

""" ) 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()