Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import pickle | |
| import requests | |
| from bs4 import BeautifulSoup | |
| # === Custom CSS for better styling === | |
| def load_css(): | |
| st.markdown(""" | |
| <style> | |
| /* Main app styling */ | |
| .main-header { | |
| text-align: center; | |
| padding: 2rem 0; | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| border-radius: 10px; | |
| margin-bottom: 2rem; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| .main-header h1 { | |
| font-size: 2.5rem; | |
| margin-bottom: 0.5rem; | |
| text-shadow: 2px 2px 4px rgba(0,0,0,0.3); | |
| } | |
| .main-header p { | |
| font-size: 1.1rem; | |
| opacity: 0.9; | |
| margin: 0; | |
| } | |
| /* Card styling */ | |
| .info-card { | |
| background: white; | |
| padding: 1.5rem; | |
| border-radius: 10px; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| border-left: 4px solid #667eea; | |
| margin: 1rem 0; | |
| } | |
| /* Results styling */ | |
| .result-positive { | |
| background: linear-gradient(135deg, #ff6b6b, #ff8e8e); | |
| color: white; | |
| padding: 1rem; | |
| border-radius: 8px; | |
| margin: 0.5rem 0; | |
| box-shadow: 0 2px 4px rgba(255, 107, 107, 0.3); | |
| } | |
| .result-negative { | |
| background: linear-gradient(135deg, #51cf66, #69db7c); | |
| color: white; | |
| padding: 1rem; | |
| border-radius: 8px; | |
| margin: 0.5rem 0; | |
| box-shadow: 0 2px 4px rgba(81, 207, 102, 0.3); | |
| } | |
| /* Button styling */ | |
| .stButton > button { | |
| background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); | |
| color: white; | |
| border: none; | |
| border-radius: 25px; | |
| padding: 0.75rem 2rem; | |
| font-weight: bold; | |
| transition: all 0.3s ease; | |
| box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); | |
| } | |
| .stButton > button:hover { | |
| transform: translateY(-2px); | |
| box-shadow: 0 6px 8px rgba(0, 0, 0, 0.15); | |
| } | |
| /* Radio button styling */ | |
| .stRadio > div { | |
| background: white; | |
| padding: 1rem; | |
| border-radius: 10px; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); | |
| } | |
| /* Text area styling */ | |
| .stTextArea > div > div > textarea { | |
| border-radius: 10px; | |
| border: 2px solid #e0e0e0; | |
| transition: border-color 0.3s ease; | |
| } | |
| .stTextArea > div > div > textarea:focus { | |
| border-color: #667eea; | |
| box-shadow: 0 0 10px rgba(102, 126, 234, 0.2); | |
| } | |
| /* Expander styling */ | |
| .streamlit-expanderHeader { | |
| background: linear-gradient(135deg, #f8f9fa, #e9ecef); | |
| border-radius: 10px; | |
| border: 1px solid #dee2e6; | |
| } | |
| /* Progress indicator */ | |
| .progress-text { | |
| text-align: center; | |
| font-weight: bold; | |
| color: #667eea; | |
| margin: 1rem 0; | |
| } | |
| /* Improved ingredient list styling - single div */ | |
| .ingredients-container { | |
| background: #f8f9fa; | |
| padding: 1rem 1.5rem; | |
| margin: 1rem 0; | |
| border-radius: 10px; | |
| border-left: 4px solid #667eea; | |
| box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); | |
| line-height: 1.6; | |
| font-size: 1rem; | |
| } | |
| .ingredients-container strong { | |
| color: #495057; | |
| font-weight: 600; | |
| } | |
| /* Footer */ | |
| .footer { | |
| text-align: center; | |
| padding: 2rem 0; | |
| color: #6c757d; | |
| border-top: 1px solid #e9ecef; | |
| margin-top: 3rem; | |
| } | |
| </style> | |
| """, unsafe_allow_html=True) | |
| # === Load TF-IDF Vectorizer === | |
| def load_vectorizer(): | |
| with open("saved_models/tfidf_vectorizer.pkl", "rb") as f: | |
| return pickle.load(f) | |
| # === Load XGBoost Model === | |
| def load_model(): | |
| with open("saved_models/XGBoost_model.pkl", "rb") as f: | |
| return pickle.load(f) | |
| # === Prediksi === | |
| def predict_allergen(model, vectorizer, input_text): | |
| X_input = vectorizer.transform([input_text]) | |
| prediction = model.predict(X_input) | |
| return prediction[0] | |
| # === Scraping bahan dari Cookpad === | |
| def get_ingredients_from_cookpad(url): | |
| headers = {"User-Agent": "Mozilla/5.0"} | |
| try: | |
| response = requests.get(url, headers=headers) | |
| if response.status_code != 200: | |
| return None, "Gagal mengambil halaman." | |
| soup = BeautifulSoup(response.text, "html.parser") | |
| ingredient_div = soup.find("div", class_="ingredient-list") | |
| if not ingredient_div: | |
| return None, "Tidak menemukan elemen bahan." | |
| ingredients = [] | |
| for item in ingredient_div.find_all("li"): | |
| amount = item.find("bdi") | |
| name = item.find("span") | |
| if amount and name: | |
| ingredients.append(f"{amount.get_text(strip=True)} {name.get_text(strip=True)}") | |
| else: | |
| ingredients.append(item.get_text(strip=True)) | |
| return ingredients, None | |
| except Exception as e: | |
| return None, f"Terjadi kesalahan: {str(e)}" | |
| # === Display results with custom styling === | |
| def display_results(results): | |
| st.markdown("### π― Hasil Analisis Alergen") | |
| positive_results = [] | |
| negative_results = [] | |
| for allergen, status in results.items(): | |
| if status == 1: | |
| positive_results.append(allergen) | |
| else: | |
| negative_results.append(allergen) | |
| # Display positive results (allergens detected) | |
| if positive_results: | |
| st.markdown("#### β οΈ **Alergen Terdeteksi:**") | |
| for allergen in positive_results: | |
| st.markdown(f'<div class="result-positive">π¨ <strong>{allergen}</strong></div>', unsafe_allow_html=True) | |
| # Display negative results (safe allergens) | |
| if negative_results: | |
| st.markdown("#### β **Aman dari Alergen:**") | |
| for allergen in negative_results: | |
| st.markdown(f'<div class="result-negative">β {allergen}</div>', unsafe_allow_html=True) | |
| # Show summary | |
| if not positive_results: | |
| st.markdown('<div class="result-negative">π <strong>Tidak ada alergen berbahaya terdeteksi!</strong></div>', unsafe_allow_html=True) | |
| # === Main UI === | |
| def main(): | |
| st.set_page_config( | |
| page_title="Deteksi Alergen Makanan", | |
| page_icon="π₯", | |
| layout="wide", | |
| initial_sidebar_state="expanded" | |
| ) | |
| # Load custom CSS | |
| load_css() | |
| # Header | |
| st.markdown(""" | |
| <div class="main-header"> | |
| <h1>π₯ Deteksi Alergen Makanan</h1> | |
| <p>Analisis kandungan alergen dalam resep makanan dengan teknologi AI</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # Sidebar info | |
| with st.sidebar: | |
| st.markdown("### π Informasi Alergen") | |
| st.markdown(""" | |
| **Alergen yang dapat dideteksi:** | |
| - π₯ Susu | |
| - π₯ Kacang | |
| - π₯ Telur | |
| - π¦ Makanan Laut | |
| - πΎ Gandum | |
| """) | |
| st.markdown("### π‘ Tips Penggunaan") | |
| st.markdown(""" | |
| - Masukkan bahan dengan detail | |
| - Gunakan nama bahan dalam bahasa Indonesia | |
| - Untuk URL Cookpad, pastikan link valid | |
| - Maksimal 20 URL per analisis | |
| """) | |
| # Main content | |
| col1, col2, col3 = st.columns([1, 6, 1]) | |
| with col2: | |
| # Input method selection | |
| st.markdown("### π§ Pilih Metode Input") | |
| input_mode = st.radio( | |
| "", | |
| ["π Input Manual", "π URL Cookpad"], | |
| horizontal=True | |
| ) | |
| # Load model components | |
| try: | |
| vectorizer = load_vectorizer() | |
| model = load_model() | |
| labels = ['Susu', 'Kacang', 'Telur', 'Makanan Laut', 'Gandum'] | |
| except Exception as e: | |
| st.error(f"β Gagal memuat model: {str(e)}") | |
| st.stop() | |
| st.markdown("---") | |
| if input_mode == "π Input Manual": | |
| st.markdown("### π Masukkan Bahan Makanan") | |
| # Info card | |
| st.markdown(""" | |
| <div class="info-card"> | |
| <strong>π‘ Petunjuk:</strong> Masukkan daftar bahan makanan yang ingin dianalisis. | |
| Pisahkan setiap bahan dengan koma atau baris baru. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| input_text = st.text_area( | |
| "", | |
| height=150, | |
| placeholder="Contoh: telur, susu, tepung terigu, garam, mentega..." | |
| ) | |
| col_btn1, col_btn2, col_btn3 = st.columns([2, 2, 2]) | |
| with col_btn2: | |
| if st.button("π Analisis Alergen", use_container_width=True): | |
| if not input_text.strip(): | |
| st.warning("β οΈ Mohon masukkan bahan makanan terlebih dahulu.") | |
| else: | |
| with st.spinner("π Sedang menganalisis..."): | |
| pred = predict_allergen(model, vectorizer, input_text) | |
| results = dict(zip(labels, pred)) | |
| st.success("β Analisis selesai!") | |
| display_results(results) | |
| elif input_mode == "π URL Cookpad": | |
| st.markdown("### π Analisis dari URL Cookpad") | |
| # Info card | |
| st.markdown(""" | |
| <div class="info-card"> | |
| <strong>π‘ Petunjuk:</strong> Masukkan hingga 20 URL resep dari Cookpad. | |
| Setiap URL harus dalam baris terpisah. | |
| </div> | |
| """, unsafe_allow_html=True) | |
| urls_input = st.text_area( | |
| "", | |
| placeholder="https://cookpad.com/id/resep/...\nhttps://cookpad.com/id/resep/...", | |
| height=200 | |
| ) | |
| urls = [url.strip() for url in urls_input.splitlines() if url.strip()] | |
| if len(urls) > 20: | |
| st.warning("β οΈ Maksimal hanya bisa memproses 20 URL. Menggunakan 20 URL pertama.") | |
| urls = urls[:20] | |
| if urls: | |
| st.info(f"π Siap memproses {len(urls)} URL") | |
| if st.button("π Analisis dari URL", use_container_width=True): | |
| if not urls: | |
| st.warning("β οΈ Mohon masukkan minimal satu URL.") | |
| else: | |
| # Progress bar | |
| progress_bar = st.progress(0) | |
| status_text = st.empty() | |
| for i, url in enumerate(urls): | |
| # Update progress | |
| progress = (i + 1) / len(urls) | |
| progress_bar.progress(progress) | |
| status_text.markdown(f'<div class="progress-text">Memproses resep {i+1} dari {len(urls)}</div>', unsafe_allow_html=True) | |
| ingredients, error = get_ingredients_from_cookpad(url) | |
| with st.expander(f"π Resep #{i+1}", expanded=False): | |
| st.markdown(f"**URL:** {url}") | |
| if error: | |
| st.error(f"β {error}") | |
| else: | |
| st.success("β Bahan berhasil diambil!") | |
| # Display ingredients in a single nice container | |
| ingredients_text = ", ".join(ingredients) | |
| st.markdown(f''' | |
| <div class="ingredients-container"> | |
| <strong>π§Ύ Daftar Bahan:</strong><br> | |
| {ingredients_text} | |
| </div> | |
| ''', unsafe_allow_html=True) | |
| # Predict allergens | |
| joined_ingredients = " ".join(ingredients) | |
| pred = predict_allergen(model, vectorizer, joined_ingredients) | |
| results = dict(zip(labels, pred)) | |
| st.markdown("---") | |
| display_results(results) | |
| # Clear progress indicators | |
| progress_bar.empty() | |
| status_text.empty() | |
| st.success("π Semua resep telah dianalisis!") | |
| # Footer | |
| st.markdown(""" | |
| <div class="footer"> | |
| <p>π¬ Powered by XGBoost & TF-IDF | Made with β€οΈ using Streamlit</p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if __name__ == "__main__": | |
| main() |