SDK-Streamlit / app.py
Gilgarmesh's picture
Upload app.py
4c03db9 verified
Raw
History Blame Contribute Delete
49.3 kB
"""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>")
@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"""
<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()