"""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"""
{names}
""" ) def render_wizard_header(title: str, subtitle: str = "") -> None: sub = f'{html.escape(subtitle)}
' if subtitle else "" md( f"""You can retry or enter the plate breakdown manually below.
We could not analyze the image automatically. You can retry or adjust the estimated plate breakdown manually below.
Automatic detection had issues earlier. Adjust portions below β nutrition updates as you edit.
Meal balance score
{html.escape(label)}
{html.escape(hint)}
Total portion: {totals["grams"]:.0f} g
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
Assigned: {assigned:.0f} g / {total_plate_grams:.0f} g Β· Remaining: {other_g:.0f} g
' f'{meta["icon"]} {html.escape(meta["label"])} Β· ' f'{share_pct:.0f}% Β· {grams_by_class[cls]:.0f} g
' f'" ) bar_parts.append("Drag and drop an image here, or click to browse
Supports JPG, PNG, WEBP, and HEIC
{html.escape(name)}
{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('{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 = '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