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; | |
} | |
/* Ingredient list styling */ | |
.ingredient-item { | |
background: #f8f9fa; | |
padding: 0.5rem 1rem; | |
margin: 0.25rem 0; | |
border-radius: 20px; | |
border-left: 3px solid #667eea; | |
} | |
/* 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 nice format | |
st.markdown("**π§Ύ Daftar Bahan:**") | |
for ing in ingredients: | |
st.markdown(f'<div class="ingredient-item">β’ {ing}</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() |