Spaces:
Build error
Build error
# ============================================================================== | |
# 1. IMPORT LIBRARY | |
# ============================================================================== | |
import streamlit as st | |
import cv2 | |
import numpy as np | |
import re | |
import pandas as pd | |
from PIL import Image | |
import time | |
from paddleocr import PaddleOCR, draw_ocr | |
import openai | |
# ============================================================================== | |
# 2. KONFIGURASI APLIKASI | |
# ============================================================================== | |
# Konfigurasi halaman Streamlit (sebaiknya dipanggil sekali di awal) | |
st.set_page_config( | |
page_title="Nutri-Grade Calculator", | |
page_icon="π", | |
layout="centered", | |
initial_sidebar_state="auto" | |
) | |
# --- Konfigurasi Kunci API dan Model --- | |
# Menggunakan st.secrets untuk keamanan, jangan hardcode kunci API! | |
# Buat file .streamlit/secrets.toml di repo Hugging Face Anda. | |
# Isinya: | |
OPENAI_API_KEY = "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" | |
try: | |
openai.api_key = st.secrets["OPENAI_API_KEY"] | |
except (KeyError, FileNotFoundError): | |
st.error("Kunci API OpenRouter tidak ditemukan. Harap atur di st.secrets.") | |
st.stop() | |
openai.api_base = "https://openrouter.ai/api/v1" | |
AI_MODEL_NAME = "qwen/qwen2.5-vl-72b-instruct:free" | |
# --- Variabel Global dan Konstanta --- | |
TARGET_KEYS = { | |
"gula": ["gula", "sugar"], | |
"takaran saji": ["takaran saji", "serving size"], | |
"lemak jenuh": ["lemak jenuh", "saturated fat"] | |
} | |
# ============================================================================== | |
# 3. FUNGSI-FUNGSI UTAMA | |
# ============================================================================== | |
def load_ocr_model(): | |
""" | |
Memuat model PaddleOCR dan menyimpannya di cache. | |
Menggunakan CPU untuk kompatibilitas yang lebih baik di Hugging Face Spaces. | |
""" | |
print("Memuat model PaddleOCR...") | |
# PENTING: use_gpu=False untuk stabilitas di environment tanpa GPU yang terkonfigurasi. | |
# Ini adalah perbaikan utama untuk error 'Failed to parse program_desc'. | |
return PaddleOCR(use_gpu=False, lang='id', cls=True) | |
def parse_numeric_value(text: str) -> float: | |
""" | |
Membersihkan string dan mengubahnya menjadi float. | |
Contoh: "15g" -> 15.0 atau "Sekitar 12.5" -> 12.5 | |
""" | |
if not isinstance(text, str): | |
return 0.0 | |
# Mengambil semua digit, titik, dan tanda minus | |
cleaned = re.sub(r"[^\d\.\-]", "", text) | |
try: | |
return float(cleaned) | |
except (ValueError, TypeError): | |
return 0.0 | |
def perform_ocr(image_path: str, ocr_model) -> list: | |
""" | |
Melakukan OCR pada gambar dan mengembalikan hasil dalam format yang terstruktur. | |
""" | |
if not image_path: | |
return [] | |
result = ocr_model.ocr(image_path, cls=True) | |
if not result or not result[0]: | |
return [] | |
ocr_list = [] | |
for line in result[0]: | |
box = line[0] | |
text, score = line[1] | |
xs = [pt[0] for pt in box] | |
ys = [pt[1] for pt in box] | |
ocr_list.append({ | |
"text": text, | |
"box": box, | |
"score": score, | |
"center_x": sum(xs) / len(xs), | |
"center_y": sum(ys) / len(ys), | |
"height": max(ys) - min(ys) | |
}) | |
# Urutkan berdasarkan posisi vertikal (atas ke bawah) | |
return sorted(ocr_list, key=lambda x: x["center_y"]) | |
def extract_key_values(ocr_data: list, target_keys: dict) -> dict: | |
""" | |
Mengekstrak pasangan key-value dari data OCR yang telah diproses. | |
""" | |
extracted = {} | |
# Pass 1: Mencari key yang diikuti oleh titik dua (contoh: "Gula: 10g") | |
for item in ocr_data: | |
txt_lower = item["text"].lower() | |
if ":" in txt_lower: | |
parts = txt_lower.split(":", 1) | |
key_candidate, value_candidate = parts[0].strip(), parts[1].strip() | |
for canonical, variants in target_keys.items(): | |
if canonical.capitalize() not in extracted: | |
for variant in variants: | |
if variant in key_candidate: | |
clean_value = re.sub(r"[^\d\.\-]", "", value_candidate) | |
if clean_value and clean_value != ".": | |
extracted[canonical.capitalize()] = clean_value | |
break | |
# Pass 2: Fallback, mencari nilai yang paling dekat di sebelah kanan key | |
for item in ocr_data: | |
txt_lower = item["text"].lower() | |
for canonical, variants in target_keys.items(): | |
if canonical.capitalize() not in extracted: | |
for variant in variants: | |
if variant in txt_lower: | |
key_center_y, key_center_x, key_height = item["center_y"], item["center_x"], item["height"] | |
best_candidate = None | |
min_horizontal_dist = float('inf') | |
for other in ocr_data: | |
# Cari kandidat di sebelah kanan dan sejajar secara vertikal | |
is_aligned_y = abs(other["center_y"] - key_center_y) < key_height * 0.75 | |
is_to_the_right = other["center_x"] > key_center_x | |
if item != other and is_aligned_y and is_to_the_right: | |
horizontal_dist = other["center_x"] - key_center_x | |
if horizontal_dist < min_horizontal_dist: | |
min_horizontal_dist = horizontal_dist | |
best_candidate = other | |
if best_candidate: | |
raw_value = best_candidate["text"] | |
clean_value = re.sub(r"[^\d\.\-]", "", raw_value) | |
if clean_value and clean_value != ".": | |
extracted[canonical.capitalize()] = clean_value | |
break # Pindah ke canonical key berikutnya | |
return extracted | |
def calculate_final_grade(sugar_norm: float, fat_norm: float) -> (str, str, str): | |
""" | |
Menghitung grade untuk gula, lemak jenuh, dan grade akhir. | |
""" | |
thresholds = { | |
"sugar": {"A": 1.0, "B": 5.0, "C": 10.0}, | |
"fat": {"A": 0.7, "B": 1.2, "C": 2.8} | |
} | |
grade_scores = {"A": 1, "B": 2, "C": 3, "D": 4} | |
def get_grade(value, nutrient_type): | |
if value <= thresholds[nutrient_type]["A"]: return "A" | |
if value <= thresholds[nutrient_type]["B"]: return "B" | |
if value <= thresholds[nutrient_type]["C"]: return "C" | |
return "D" | |
sugar_grade = get_grade(sugar_norm, "sugar") | |
fat_grade = get_grade(fat_norm, "fat") | |
worst_score = max(grade_scores[sugar_grade], grade_scores[fat_grade]) | |
final_grade = next(grade for grade, score in grade_scores.items() if score == worst_score) | |
return f"Grade {sugar_grade}", f"Grade {fat_grade}", f"Grade {final_grade}" | |
def generate_nutrition_advice(data: dict) -> str: | |
""" | |
Membuat prompt dan memanggil API LLM untuk mendapatkan saran nutrisi. | |
""" | |
nutrition_prompt = f""" | |
Anda adalah seorang ahli gizi dari Indonesia yang ramah, komunikatif, dan berpengalaman. | |
Berikut adalah data nutrisi sebuah produk makanan: | |
- Takaran Saji: {data['serving_size']:.2f} g/ml | |
- Kandungan Gula (setelah normalisasi per 100g): {data['sugar_norm']:.2f} g | |
- Kandungan Lemak Jenuh (setelah normalisasi per 100g): {data['fat_norm']:.2f} g | |
- Grade Gula: {data['sugar_grade']} | |
- Grade Lemak Jenuh: {data['fat_grade']} | |
- Grade Akhir Produk: {data['final_grade']} | |
Tugas Anda: | |
Berikan saran nutrisi yang informatif dalam satu paragraf pendek (sekitar 50-100 kata). | |
Gunakan bahasa yang bersahabat dan mudah dimengerti. Jelaskan secara ringkas arti dari data nutrisi di atas, | |
dampak kesehatan terkait, dan berikan tips praktis untuk menjaga pola makan seimbang. | |
""" | |
st.write("Tunggu sebentar, Qwen si AI nutritionist sedang memproses penjelasannya... π€") | |
try: | |
completion = openai.ChatCompletion.create( | |
model=AI_MODEL_NAME, | |
messages=[{"role": "user", "content": nutrition_prompt}] | |
) | |
return completion.choices[0].message.content | |
except Exception as e: | |
return f"Gagal mendapatkan saran dari Qwen: {e}" | |
def display_colored_grade(grade_text: str): | |
""" | |
Menampilkan grade akhir dengan warna latar yang sesuai. | |
""" | |
color_map = { | |
"Grade A": "#2ecc71", # Hijau | |
"Grade B": "#f1c40f", # Kuning | |
"Grade C": "#e67e22", # Oranye | |
"Grade D": "#e74c3c" # Merah | |
} | |
bg_color = color_map.get(grade_text, "#7f8c8d") # Default abu-abu | |
html_code = f""" | |
<div style=" | |
background-color: {bg_color}; | |
padding: 15px; | |
border-radius: 8px; | |
margin-top: 10px; | |
font-weight: bold; | |
color: white; | |
text-align: center; | |
font-size: 20px; | |
"> | |
{grade_text} | |
</div> | |
""" | |
st.markdown(html_code, unsafe_allow_html=True) | |
# ============================================================================== | |
# 4. TAMPILAN ANTARMUKA (USER INTERFACE) | |
# ============================================================================== | |
# --- Judul dan Deskripsi --- | |
st.title("π Nutri-Grade Label & Grade Calculator") | |
st.caption("Aplikasi prototipe untuk menganalisis dan memberi grade pada label nutrisi produk, terinspirasi oleh Nutri-Grade Singapura. Refresh halaman jika terjadi masalah.") | |
# --- Petunjuk Penggunaan dan Info --- | |
with st.expander("Petunjuk Penggunaan π"): | |
st.markdown(""" | |
1. **Upload Gambar**: Unggah gambar tabel gizi produk. Jika dari ponsel, Anda bisa langsung menggunakan kamera. | |
2. **Deteksi Teks (OCR)**: Sistem akan secara otomatis mendeteksi teks dan angka pada gambar. | |
3. **Koreksi Manual**: Periksa hasil deteksi. Jika ada yang kurang tepat, Anda bisa memperbaikinya di formulir. | |
4. **Hitung Grade**: Klik tombol "Hitung" untuk melihat hasil analisis, grade, dan saran nutrisi. | |
""") | |
with st.expander("β οΈ Harap Diperhatikan"): | |
st.markdown(""" | |
- Aplikasi ini masih dalam tahap **pengembangan (prototipe)**. | |
- Hasil ekstraksi otomatis mungkin tidak 100% akurat. **Selalu verifikasi dengan label fisik**. | |
- Dijalankan pada server gratis, mohon maaf jika terkadang lambat atau mengalami kendala. | |
- Kode sumber tersedia di [Hugging Face](https://huggingface.co/spaces/tataaditya/nutri-grade). Kontribusi dan feedback sangat kami hargai. | |
- Referensi utama: [Health Promotion Board Singapore](https://www.hpb.gov.sg/docs/default-source/pdf/nutri-grade-ci-guide_eng-only67e4e36349ad4274bfdb22236872336d.pdf). | |
""") | |
# --- Inisialisasi Model OCR --- | |
ocr_model = load_ocr_model() | |
# --- STEP 1: Upload Gambar --- | |
uploaded_file = st.file_uploader( | |
"Upload gambar tabel gizi di sini (JPG/PNG)", | |
type=["jpg", "jpeg", "png"] | |
) | |
if uploaded_file is not None: | |
# Menggunakan session state untuk menyimpan hasil agar tidak perlu diulang | |
if 'last_uploaded_file' not in st.session_state or st.session_state.last_uploaded_file != uploaded_file.name: | |
st.session_state.last_uploaded_file = uploaded_file.name | |
st.session_state.ocr_data = None | |
st.session_state.extracted_data = {} | |
# Konversi dan tampilkan gambar | |
image_bytes = np.asarray(bytearray(uploaded_file.read()), dtype=np.uint8) | |
img = cv2.imdecode(image_bytes, 1) | |
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) | |
st.image(img_rgb, caption="Gambar yang diunggah", use_column_width=True) | |
# Simpan gambar sementara untuk diproses OCR | |
img_path = "uploaded_image.jpg" | |
cv2.imwrite(img_path, img) | |
# --- STEP 2: Proses OCR (hanya jika belum ada datanya) --- | |
if st.session_state.ocr_data is None: | |
with st.spinner("Membaca teks dari gambar... Ini mungkin memakan waktu beberapa detik."): | |
start_time = time.time() | |
st.session_state.ocr_data = perform_ocr(img_path, ocr_model) | |
ocr_time = time.time() - start_time | |
if not st.session_state.ocr_data: | |
st.error("OCR tidak dapat menemukan teks apapun pada gambar. Coba gambar yang lebih jelas.") | |
st.stop() | |
else: | |
st.success(f"OCR berhasil! Ditemukan {len(st.session_state.ocr_data)} baris teks dalam {ocr_time:.2f} detik.") | |
st.session_state.extracted_data = extract_key_values(st.session_state.ocr_data, TARGET_KEYS) | |
# Tampilkan hasil OCR dengan bounding box untuk referensi | |
with st.expander("Lihat Hasil Deteksi Teks (OCR)"): | |
boxes_ocr = [line["box"] for line in st.session_state.ocr_data] | |
texts_ocr = [line["text"] for line in st.session_state.ocr_data] | |
scores_ocr = [line["score"] for line in st.session_state.ocr_data] | |
# Gunakan font default jika simfang tidak ada | |
try: | |
im_show = draw_ocr(Image.open(img_path).convert("RGB"), boxes_ocr, texts_ocr, scores_ocr, font_path="simfang.ttf") | |
except: | |
im_show = draw_ocr(Image.open(img_path).convert("RGB"), boxes_ocr, texts_ocr, scores_ocr) | |
im_show = Image.fromarray(im_show) | |
st.image(im_show, caption="Hasil OCR dengan Bounding Boxes", use_column_width=True) | |
# --- STEP 3: Koreksi Manual --- | |
st.markdown("---") | |
st.subheader("Verifikasi & Koreksi Data") | |
st.info("Periksa dan koreksi nilai yang diekstrak jika perlu. Masukkan **hanya angka** (gunakan titik untuk desimal).") | |
with st.form("correction_form"): | |
corrected_data = {} | |
# Ambil nilai dari session state sebagai default | |
extracted_data = st.session_state.extracted_data | |
for key in TARGET_KEYS.keys(): | |
key_cap = key.capitalize() | |
# Ambil nilai yang sudah diekstrak, jika tidak ada, biarkan kosong | |
default_val = extracted_data.get(key_cap, "") | |
corrected_data[key_cap] = st.text_input( | |
label=f"**{key_cap}** (angka saja)", | |
value=default_val | |
) | |
submit_button = st.form_submit_button("β Hitung Grade & Dapatkan Saran") | |
# --- STEP 4: Kalkulasi dan Tampilan Hasil --- | |
if submit_button: | |
try: | |
# Ambil nilai dari form yang sudah dikoreksi | |
serving_size = parse_numeric_value(corrected_data.get("Takaran saji", "100")) | |
sugar_value = parse_numeric_value(corrected_data.get("Gula", "0")) | |
fat_value = parse_numeric_value(corrected_data.get("Lemak jenuh", "0")) | |
if serving_size <= 0: | |
st.error("Takaran Saji harus lebih besar dari nol untuk melakukan normalisasi.") | |
st.stop() | |
# Normalisasi ke per 100g/ml | |
sugar_norm = (sugar_value / serving_size) * 100 | |
fat_norm = (fat_value / serving_size) * 100 | |
# Hitung Grade | |
sugar_grade, fat_grade, final_grade = calculate_final_grade(sugar_norm, fat_norm) | |
st.markdown("---") | |
st.subheader("Hasil Analisis Nutrisi") | |
col1, col2 = st.columns(2) | |
with col1: | |
st.write("**Hasil Normalisasi per 100 g/ml**") | |
df_tabel = pd.DataFrame({ | |
"Nutrisi": ["Gula Total", "Lemak Jenuh"], | |
"Nilai (per 100 g/ml)": [f"{sugar_norm:.2f} g", f"{fat_norm:.2f} g"] | |
}) | |
st.table(df_tabel) | |
with col2: | |
st.write("**Hasil Penilaian Grade**") | |
st.metric(label="Grade Gula", value=sugar_grade) | |
st.metric(label="Grade Lemak Jenuh", value=fat_grade) | |
st.write("**Grade Akhir Produk**") | |
display_colored_grade(final_grade) | |
st.markdown("---") | |
st.subheader("Saran dari Ahli Gizi AI") | |
advice_data = { | |
"serving_size": serving_size, "sugar_norm": sugar_norm, "fat_norm": fat_norm, | |
"sugar_grade": sugar_grade, "fat_grade": fat_grade, "final_grade": final_grade | |
} | |
nutrition_advice = generate_nutrition_advice(advice_data) | |
st.success(nutrition_advice) | |
except Exception as e: | |
st.error(f"Terjadi kesalahan saat perhitungan: {e}") | |
# ============================================================================== | |
# 5. FOOTER | |
# ============================================================================== | |
st.markdown("---") | |
# --- Tampilan Tim Pengembang --- | |
st.markdown(""" | |
<div style="border: 1px solid #dfe6e9; padding: 15px; border-radius: 10px; margin-top: 20px; background-color: #fafafa;"> | |
<h4 style="text-align: center; color: #007BFF;">Tim Pengembang</h4> | |
<p><strong>Nicholas Dominic</strong>, Mentor - <a href="https://www.linkedin.com/in/nicholas-dominic" target="_blank">LinkedIn</a></p> | |
<p><strong>Tata Aditya Pamungkas</strong>, Machine Learning - <a href="https://www.linkedin.com/in/tata-aditya-pamungkas" target="_blank">LinkedIn</a></p> | |
<p><strong>Raihan Hafiz</strong>, Web Dev - <a href="https://www.linkedin.com/in/m-raihan-hafiz-91a368186" target="_blank">LinkedIn</a></p> | |
</div> | |
""", unsafe_allow_html=True) | |
with st.expander("Rencana Pengembangan & Inovasi Selanjutnya π"): | |
st.markdown(""" | |
1. **Infrastruktur yang Lebih Baik**: Migrasi ke server berbayar untuk meningkatkan kecepatan, stabilitas, dan kapasitas pengguna. | |
2. **Fitur Food Recall**: Mengembangkan fitur untuk mencatat asupan makanan harian (*real food*), bukan hanya produk kemasan. Ide ini divalidasi setelah diskusi dengan nutritionist [Firza Marhamah](https://www.linkedin.com/in/firza-marhamah). | |
3. **Kalkulator Kalori Harian**: Menambahkan fitur penghitung kebutuhan kalori harian yang dipersonalisasi berdasarkan data pengguna (usia, berat badan, tinggi badan, tingkat aktivitas). | |
""") |