Spaces:
Sleeping
Sleeping
| """Gradio application for the smart fridge detector + recipe recommendation pipeline.""" | |
| import json | |
| import tempfile | |
| from pathlib import Path | |
| from typing import List, Tuple, Dict, Any | |
| import cv2 | |
| import gradio as gr | |
| import numpy as np | |
| from PIL import Image | |
| from frige_detect.detect import ( | |
| detect_and_generate, | |
| load_roboflow_credentials, | |
| RoboflowCredentials, | |
| ) | |
| from recipe_recommendation.main import ( | |
| load_recipes, | |
| recommend_recipes, | |
| save_user_profile, | |
| get_feedback, | |
| USER_DATA_DIR, | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # Global resources | |
| # --------------------------------------------------------------------------- | |
| CREDENTIALS_PATH = Path("frige_detect/roboflow_credentials.txt") | |
| ROBOFLOW_CREDENTIALS: RoboflowCredentials = load_roboflow_credentials(str(CREDENTIALS_PATH)) | |
| RECIPES_DF = load_recipes() | |
| # --------------------------------------------------------------------------- | |
| # Predefined user profiles for examples | |
| # --------------------------------------------------------------------------- | |
| EXAMPLE_PROFILES = { | |
| "user_1": { | |
| "vegetarian_type": "flexible", | |
| "allergies": "", | |
| "regions": "North America", | |
| "calorie_min": 250, | |
| "calorie_max": 2000, | |
| "protein_min": 50, | |
| "protein_max": 160, | |
| "preferred_main": "", | |
| "disliked_main": "", | |
| "cooking_time": 45, | |
| }, | |
| "user_2": { | |
| "vegetarian_type": "flexible_vegetarian", | |
| "allergies": "shrimp", | |
| "regions": "Asia", | |
| "calorie_min": 400, | |
| "calorie_max": 1500, | |
| "protein_min": 40, | |
| "protein_max": 120, | |
| "preferred_main": "tofu", | |
| "disliked_main": "beef", | |
| "cooking_time": 60, | |
| }, | |
| "user_3": { | |
| "vegetarian_type": "non_vegetarian", | |
| "allergies": "", | |
| "regions": "Europe", | |
| "calorie_min": 500, | |
| "calorie_max": 2000, | |
| "protein_min": 80, | |
| "protein_max": 160, | |
| "preferred_main": "beef, chicken", | |
| "disliked_main": "", | |
| "cooking_time": 45, | |
| }, | |
| } | |
| # Predefined example images | |
| EXAMPLE_IMAGES = [ | |
| "frige_detect/demo/t1.jpg", | |
| "frige_detect/demo/t2.jpg", | |
| "frige_detect/demo/t3.jpg", | |
| ] | |
| # --------------------------------------------------------------------------- | |
| # Helper utilities | |
| # --------------------------------------------------------------------------- | |
| def parse_csv_list(text: str) -> List[str]: | |
| if not text: | |
| return [] | |
| parts = [item.strip() for item in text.split(",") if item.strip()] | |
| return parts | |
| def ensure_numpy_image(image: Any) -> np.ndarray: | |
| """Convert incoming image (PIL or numpy) to RGB numpy array.""" | |
| if image is None: | |
| raise ValueError("Please upload a fridge photo before running detection.") | |
| if isinstance(image, np.ndarray): | |
| return image | |
| if isinstance(image, Image.Image): | |
| return np.array(image.convert("RGB")) | |
| raise ValueError("Unsupported image format provided.") | |
| def write_temp_image(image: np.ndarray) -> str: | |
| """Write numpy image to a temporary file and return the path.""" | |
| temp_dir = Path(tempfile.mkdtemp(prefix="fridge_upload_")) | |
| temp_path = temp_dir / "upload.jpg" | |
| bgr_image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) | |
| cv2.imwrite(str(temp_path), bgr_image) | |
| return str(temp_path) | |
| def build_user_profile( | |
| user_id: str, | |
| vegetarian_type: str, | |
| allergies: str, | |
| regions: str, | |
| calorie_range: Tuple[float, float], | |
| protein_range: Tuple[float, float], | |
| preferred_main: str, | |
| disliked_main: str, | |
| cooking_time: float, | |
| ) -> Dict[str, Any]: | |
| """ | |
| Build and save user profile. This function ALWAYS creates or overwrites the profile | |
| with the current input values, enabling users to modify preferences on-the-fly. | |
| """ | |
| user_id = user_id.strip() | |
| if not user_id: | |
| raise ValueError("User ID cannot be empty.") | |
| profile_dir = USER_DATA_DIR / user_id | |
| profile_path = profile_dir / "user_profile.json" | |
| # Preserve feedback count if profile exists | |
| num_feedback = 0 | |
| if profile_path.exists(): | |
| try: | |
| existing = json.loads(profile_path.read_text(encoding="utf-8")) | |
| num_feedback = existing.get("num_feedback", 0) | |
| except Exception: | |
| pass | |
| profile = { | |
| "user_id": user_id, | |
| "num_feedback": num_feedback, | |
| "diet": {"vegetarian_type": vegetarian_type}, | |
| "allergies": parse_csv_list(allergies), | |
| "region_preference": parse_csv_list(regions), | |
| "nutritional_goals": { | |
| "calories": {"min": int(calorie_range[0]), "max": int(calorie_range[1])}, | |
| "protein": {"min": int(protein_range[0]), "max": int(protein_range[1])}, | |
| }, | |
| "other_preferences": { | |
| "preferred_main": parse_csv_list(preferred_main), | |
| "disliked_main": parse_csv_list(disliked_main), | |
| "cooking_time_max": int(cooking_time) if cooking_time else None, | |
| }, | |
| } | |
| # Always save the profile (create new or overwrite existing) | |
| save_user_profile(user_id, profile) | |
| print(f"[app] Profile saved/updated for user '{user_id}'") | |
| return profile | |
| def summarize_ingredients( | |
| user_parents: List[str], | |
| high_conf: List[str], | |
| low_conf: List[str], | |
| ) -> str: | |
| lines = ["### Ingredient Mapping"] | |
| if user_parents: | |
| lines.append("- **Mapped parent ingredients:** " + ", ".join(sorted(user_parents))) | |
| else: | |
| lines.append("- **Mapped parent ingredients:** none") | |
| if high_conf: | |
| lines.append("- **High confidence detections:** " + ", ".join(sorted(high_conf))) | |
| if low_conf: | |
| lines.append("- **Low confidence detections:** " + ", ".join(sorted(set(low_conf)))) | |
| return "\n".join(lines) | |
| def _ensure_iterable(value: Any) -> List[str]: | |
| if value is None: | |
| return [] | |
| if isinstance(value, set): | |
| return sorted(value) | |
| if isinstance(value, list): | |
| return value | |
| if isinstance(value, str): | |
| return [value] | |
| return list(value) | |
| def render_recommendations(df) -> Tuple[str, List[Dict[str, Any]]]: | |
| if df is None or df.empty: | |
| return "No recipes matched the current constraints.", [] | |
| lines = ["### Recommended Recipes"] | |
| feedback_rows: List[Dict[str, Any]] = [] | |
| for idx, row in df.head(5).iterrows(): | |
| match_score = row.get("match_score") or row.get("ml_score", 0) | |
| scaled = match_score * 100 if match_score is not None else 0 | |
| name = row.get("name", f"Recipe {idx+1}") | |
| lines.append(f"{idx + 1}. **{name}** — score {scaled:.1f}%") | |
| region = row.get("region") | |
| if region and not (isinstance(region, float) and np.isnan(region)): | |
| if isinstance(region, (set, list)): | |
| region_str = ", ".join(sorted(region)) | |
| else: | |
| region_str = str(region) | |
| lines.append(f" - Region: {region_str}") | |
| cuisine = row.get("cuisine_attr") | |
| cuisine_items = _ensure_iterable(cuisine) | |
| if cuisine_items: | |
| lines.append(f" - Cuisine: {', '.join(cuisine_items)}") | |
| calories = row.get("calories") | |
| protein = row.get("protein") | |
| if calories is not None: | |
| lines.append(f" - Calories: {calories}") | |
| if protein is not None: | |
| lines.append(f" - Protein: {protein}") | |
| for key in ["main_parent", "staple_parent", "other_parent"]: | |
| parents = _ensure_iterable(row.get(key)) | |
| if parents: | |
| pretty_key = key.replace("_", " ").title() | |
| lines.append(f" - {pretty_key}: {', '.join(parents)}") | |
| ingredients = row.get("ingredients") | |
| if ingredients: | |
| if isinstance(ingredients, str): | |
| ingredients_list = parse_csv_list(ingredients) | |
| else: | |
| ingredients_list = list(ingredients) | |
| if ingredients_list: | |
| lines.append(f" - Ingredients: {', '.join(ingredients_list[:10])}") | |
| lines.append("") | |
| feedback_row = row.to_dict() | |
| for key in ["main_parent", "staple_parent", "other_parent", "seasoning_parent", "cuisine_attr", "ingredients"]: | |
| value = feedback_row.get(key) | |
| if isinstance(value, list): | |
| feedback_row[key] = set(value) | |
| elif isinstance(value, str): | |
| feedback_row[key] = set(parse_csv_list(value)) | |
| feedback_rows.append(feedback_row) | |
| return "\n".join(lines).strip(), feedback_rows | |
| def load_example_profile(profile_name: str): | |
| """Load a predefined user profile configuration.""" | |
| if profile_name in EXAMPLE_PROFILES: | |
| config = EXAMPLE_PROFILES[profile_name] | |
| return ( | |
| profile_name, | |
| config["vegetarian_type"], | |
| config["allergies"], | |
| config["regions"], | |
| config["calorie_min"], | |
| config["calorie_max"], | |
| config["protein_min"], | |
| config["protein_max"], | |
| config["preferred_main"], | |
| config["disliked_main"], | |
| config["cooking_time"], | |
| ) | |
| # Default fallback | |
| return ("user_custom", "flexible", "", "", 400, 2000, 50, 160, "", "", 45) | |
| def load_example_image(image_path: str): | |
| """Load an example image.""" | |
| return image_path | |
| def run_pipeline( | |
| image, | |
| user_id, | |
| vegetarian_type, | |
| allergies, | |
| regions, | |
| calorie_min, | |
| calorie_max, | |
| protein_min, | |
| protein_max, | |
| preferred_main, | |
| disliked_main, | |
| cooking_time, | |
| ): | |
| """ | |
| Main pipeline function. | |
| This ALWAYS creates/updates the user profile based on current input values, | |
| then runs detection and recommendation. | |
| """ | |
| try: | |
| rgb_image = ensure_numpy_image(image) | |
| upload_path = write_temp_image(rgb_image) | |
| temp_dir = Path(tempfile.mkdtemp(prefix="fridge_outputs_")) | |
| output_json = temp_dir / "recipe_input.json" | |
| output_image = temp_dir / "annotated_image.jpg" | |
| detection_result = detect_and_generate( | |
| image_path=upload_path, | |
| credentials=ROBOFLOW_CREDENTIALS, | |
| conf_threshold=0.4, | |
| overlap_threshold=0.3, | |
| conf_split=0.7, | |
| output_json=str(output_json), | |
| output_image=str(output_image), | |
| ) | |
| Path(upload_path).unlink(missing_ok=True) | |
| #2: Always create/update user profile with current UI values | |
| profile = build_user_profile( | |
| user_id, | |
| vegetarian_type, | |
| allergies, | |
| regions, | |
| (calorie_min, calorie_max), | |
| (protein_min, protein_max), | |
| preferred_main, | |
| disliked_main, | |
| cooking_time, | |
| ) | |
| import time | |
| time.sleep(0.2) | |
| detection_payload = detection_result["recipe_json"] | |
| ml_top, user_parents, high_conf, low_conf = recommend_recipes( | |
| detection_payload, | |
| user_id, | |
| RECIPES_DF, | |
| topk=5, | |
| ) | |
| ingredient_summary = summarize_ingredients(user_parents, high_conf, low_conf) | |
| recommendation_md, feedback_rows = render_recommendations(ml_top) | |
| dropdown_choices = [ | |
| f"{idx + 1}. {row.get('name', 'Recipe')}" for idx, row in enumerate(feedback_rows) | |
| ] | |
| status = "" if feedback_rows else "No recipes available for feedback yet." | |
| # Add success message about profile creation/update | |
| profile_status = f"✓ Profile '{user_id}' has been saved/updated with your current preferences." | |
| return ( | |
| str(output_image), | |
| detection_payload, | |
| ingredient_summary, | |
| recommendation_md, | |
| gr.Dropdown(choices=dropdown_choices, value=None), | |
| feedback_rows, | |
| profile_status, | |
| ) | |
| except Exception as exc: | |
| import traceback | |
| error_detail = traceback.format_exc() | |
| return ( | |
| None, | |
| None, | |
| "", | |
| f"⚠️ Error: {exc}\n\nDetails:\n{error_detail}", | |
| gr.Dropdown(choices=[], value=None), | |
| [], | |
| f"⚠️ Error: {exc}", | |
| ) | |
| def record_feedback(selected_recipe: str, user_id: str, feedback_rows: List[Dict[str, Any]]): | |
| if not selected_recipe: | |
| return "Please select a recipe before submitting feedback." | |
| if not user_id: | |
| return "Please provide a valid user ID." | |
| if not feedback_rows: | |
| return "No recommendation data available. Run the pipeline first." | |
| try: | |
| index = int(selected_recipe.split(".")[0]) - 1 | |
| except (ValueError, IndexError): | |
| return "Unable to parse the selected recipe." | |
| if index < 0 or index >= len(feedback_rows): | |
| return "Selected recipe is out of range." | |
| recipe_row = feedback_rows[index] | |
| get_feedback(user_id, recipe_row) | |
| profile_path = USER_DATA_DIR / user_id / "user_profile.json" | |
| if profile_path.exists(): | |
| data = json.loads(profile_path.read_text(encoding="utf-8")) | |
| data["num_feedback"] = data.get("num_feedback", 0) + 1 | |
| save_user_profile(user_id, data) | |
| return f"✓ Feedback recorded for {recipe_row.get('name', 'selected recipe')}!" | |
| # --------------------------------------------------------------------------- | |
| # Gradio UI definition | |
| # --------------------------------------------------------------------------- | |
| with gr.Blocks(title="Smart Fridge Recipe Assistant", theme=gr.themes.Soft()) as demo: | |
| gr.Markdown( | |
| """ | |
| # Smart Fridge Recipe Assistant | |
| **How to use:** | |
| 1. (Optional) Select an example profile and/or image from dropdowns | |
| 2. Modify any preferences in the form - your profile will be saved automatically when you click Analyze | |
| 3. Upload or select a fridge image | |
| 4. Click "Analyze fridge & recommend recipes" | |
| """ | |
| ) | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| gr.Markdown("### Quick Start Examples") | |
| profile_selector = gr.Dropdown( | |
| label="Choose a predefined user profile", | |
| choices=list(EXAMPLE_PROFILES.keys()), | |
| value=None, | |
| ) | |
| image_selector = gr.Dropdown( | |
| label="Choose an example fridge image", | |
| choices=[f"Image {i+1}: {img}" for i, img in enumerate(EXAMPLE_IMAGES)], | |
| value=None, | |
| ) | |
| image_input = gr.Image( | |
| label="Fridge photo (upload or use example)", | |
| type="pil", | |
| height=350, | |
| ) | |
| detection_json = gr.JSON(label="Detection payload") | |
| annotated_output = gr.Image(label="Annotated detection", height=350) | |
| with gr.Column(scale=1): | |
| gr.Markdown("### User Preferences (auto-saved on each run)") | |
| user_id_box = gr.Textbox( | |
| label="User ID (will create new profile if doesn't exist)", | |
| value="user_custom", | |
| placeholder="e.g. my_new_profile", | |
| ) | |
| vegetarian_radio = gr.Radio( | |
| [ | |
| "flexible", | |
| "flexible_vegetarian", | |
| "ovo_vegetarian", | |
| "lacto_vegetarian", | |
| "vegan", | |
| "non_vegetarian", | |
| ], | |
| label="Vegetarian preference", | |
| value="flexible", | |
| ) | |
| allergies_box = gr.Textbox( | |
| label="Allergies (comma separated)", | |
| placeholder="peanut, shrimp", | |
| ) | |
| regions_box = gr.Textbox( | |
| label="Preferred regions (comma separated)", | |
| placeholder="Asia, Europe", | |
| ) | |
| calorie_min = gr.Slider( | |
| minimum=0, | |
| maximum=4000, | |
| value=400, | |
| label="Minimum Calories", | |
| step=50, | |
| ) | |
| calorie_max = gr.Slider( | |
| minimum=0, | |
| maximum=4000, | |
| value=2000, | |
| label="Maximum Calories", | |
| step=50, | |
| ) | |
| protein_min = gr.Slider( | |
| minimum=0, | |
| maximum=250, | |
| value=50, | |
| label="Minimum Protein (g)", | |
| step=5, | |
| ) | |
| protein_max = gr.Slider( | |
| minimum=0, | |
| maximum=250, | |
| value=160, | |
| label="Maximum Protein (g)", | |
| step=5, | |
| ) | |
| preferred_box = gr.Textbox( | |
| label="Preferred main ingredients", | |
| placeholder="chicken, tofu", | |
| ) | |
| disliked_box = gr.Textbox( | |
| label="Disliked main ingredients", | |
| placeholder="lamb", | |
| ) | |
| cooking_slider = gr.Slider( | |
| minimum=0, | |
| maximum=180, | |
| value=45, | |
| step=5, | |
| label="Max cooking time (minutes)", | |
| ) | |
| run_button = gr.Button("Analyze fridge & recommend recipes", variant="primary") | |
| ingredient_md = gr.Markdown() | |
| recommendation_md = gr.Markdown() | |
| feedback_dropdown = gr.Dropdown(label="Select a recipe for positive feedback", choices=[]) | |
| feedback_button = gr.Button("Save feedback") | |
| feedback_status = gr.Markdown() | |
| feedback_state = gr.State([]) | |
| # Connect profile selector | |
| profile_selector.change( | |
| fn=load_example_profile, | |
| inputs=[profile_selector], | |
| outputs=[ | |
| user_id_box, | |
| vegetarian_radio, | |
| allergies_box, | |
| regions_box, | |
| calorie_min, | |
| calorie_max, | |
| protein_min, | |
| protein_max, | |
| preferred_box, | |
| disliked_box, | |
| cooking_slider, | |
| ], | |
| ) | |
| # Connect image selector | |
| def select_image(choice): | |
| if choice: | |
| idx = int(choice.split(":")[0].replace("Image ", "")) - 1 | |
| return EXAMPLE_IMAGES[idx] | |
| return None | |
| image_selector.change( | |
| fn=select_image, | |
| inputs=[image_selector], | |
| outputs=[image_input], | |
| ) | |
| run_button.click( | |
| fn=run_pipeline, | |
| inputs=[ | |
| image_input, | |
| user_id_box, | |
| vegetarian_radio, | |
| allergies_box, | |
| regions_box, | |
| calorie_min, | |
| calorie_max, | |
| protein_min, | |
| protein_max, | |
| preferred_box, | |
| disliked_box, | |
| cooking_slider, | |
| ], | |
| outputs=[ | |
| annotated_output, | |
| detection_json, | |
| ingredient_md, | |
| recommendation_md, | |
| feedback_dropdown, | |
| feedback_state, | |
| feedback_status, | |
| ], | |
| ) | |
| feedback_button.click( | |
| fn=record_feedback, | |
| inputs=[feedback_dropdown, user_id_box, feedback_state], | |
| outputs=feedback_status, | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch(share=True) |