Spaces:
Running
Running
import streamlit as st | |
import cv2 | |
import numpy as np | |
import re | |
import os | |
import pandas as pd | |
from PIL import Image | |
import time | |
from paddleocr import PaddleOCR, draw_ocr | |
import openai | |
# Set API key dan base URL untuk OpenRouter (ganti placeholder dengan nilai yang valid) | |
openai.api_key = "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" # Ganti dengan API key Anda | |
openai.api_base = "https://openrouter.ai/api/v1" | |
# Title dan Deskripsi | |
st.title("Nutri-Grade Label Detection & Grade Calculator") | |
st.caption("Selamat Datang di aplikasi prototype kami. Terinspirasi dari NutriGrade Singapura, kami berharap aplikasi ini dapat membantu teman-teman dalam memilih produk makanan yang lebih sehat. Tolong di refresh yah kalau nggak jalan") | |
# ----------------------------------------------- | |
# Info & Petunjuk Penggunaan | |
# ----------------------------------------------- | |
with st.expander("Petunjuk Penggunaan"): | |
st.markdown(""" | |
**Cara Penggunaan:** | |
1. Upload gambar, jika menggunakan smartphone pilih kamera lalu ambil foto. (kalau tidak jalan, coba refresh) | |
2. Sistem mendeteksi teks pada gambar menggunakan OCR. | |
3. Periksa dan koreksi nilai secara manual jika diperlukan. | |
4. Klik *Hitung* untuk melihat tabel normalisasi, grade, dan saran nutrisi. | |
""") | |
with st.expander("!! Tolong Diperhatikan !!"): | |
st.markdown(""" | |
1. Aplikasi ini masih dalam Pengembangan. | |
2. Hasil ekstraksi hanya sebagai gambaran; silakan koreksi bila diperlukan. | |
3. Hosting gratisan, jadi mungkin ada beberapa kendala. | |
4. Kode dapat diakses di Hugging Face untuk kontribusi atau feedback. | |
5. Referensi: [Health Promotion Board Singapura](https://www.hpb.gov.sg/docs/default-source/pdf/nutri-grade-ci-guide_eng-only67e4e36349ad4274bfdb22236872336d.pdf) | |
""") | |
# Fungsi untuk membersihkan nilai numerik (contoh: "15g" → 15.0) | |
def parse_numeric_value(text): | |
cleaned = re.sub(r"[^\d\.\-]", "", text) | |
try: | |
return float(cleaned) | |
except ValueError: | |
return 0.0 | |
# Inisialisasi model PaddleOCR | |
ocr_model = PaddleOCR(use_gpu=True, lang='id', cls=True) | |
# --- STEP 1: Upload Gambar --- | |
uploaded_file = st.file_uploader("Upload Gambar (JPG/PNG)", type=["jpg", "jpeg", "png"]) | |
if uploaded_file is not None: | |
file_bytes = np.asarray(bytearray(uploaded_file.read()), dtype=np.uint8) | |
img = cv2.imdecode(file_bytes, 1) | |
st.image(cv2.cvtColor(img, cv2.COLOR_BGR2RGB), caption="Gambar yang diupload", use_column_width=True) | |
img_path = "uploaded_image.jpg" | |
cv2.imwrite(img_path, img) | |
# --- STEP 2: OCR pada Gambar Penuh --- | |
st.write("Melakukan OCR pada gambar...") | |
start_time = time.time() | |
ocr_result = ocr_model.ocr(img_path, cls=True) | |
ocr_time = time.time() - start_time | |
st.write(f"Waktu pemrosesan OCR: {ocr_time:.2f} detik") | |
if not ocr_result or len(ocr_result[0]) == 0: | |
st.error("OCR tidak menemukan teks pada gambar!") | |
else: | |
# Ekstrak data OCR | |
ocr_data = ocr_result[0] | |
ocr_list = [] | |
for line in ocr_data: | |
box = line[0] | |
text = line[1][0] | |
score = line[1][1] | |
xs = [pt[0] for pt in box] | |
ys = [pt[1] for pt in box] | |
center_x = sum(xs) / len(xs) | |
center_y = sum(ys) / len(ys) | |
ocr_list.append({ | |
"text": text, | |
"box": box, | |
"score": score, | |
"center_x": center_x, | |
"center_y": center_y, | |
"height": max(ys) - min(ys) | |
}) | |
ocr_list = sorted(ocr_list, key=lambda x: x["center_y"]) | |
# Ekstrak pasangan key-value dengan format "key: value" | |
target_keys = { | |
"gula": ["gula"], | |
"takaran saji": ["takaran saji", "serving size"], | |
"lemak jenuh": ["lemak jenuh"] | |
} | |
extracted = {} | |
# Pass 1: Ekstraksi dengan tanda titik dua | |
for item in ocr_list: | |
txt_lower = item["text"].lower() | |
if ":" in txt_lower: | |
parts = txt_lower.split(":") | |
key_candidate = parts[0].strip() | |
value_candidate = parts[-1].strip() | |
for canonical, variants in target_keys.items(): | |
if canonical 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 untuk key yang belum diekstrak | |
for item in ocr_list: | |
txt_lower = item["text"].lower() | |
for canonical, variants in target_keys.items(): | |
if canonical not in extracted: | |
for variant in variants: | |
if variant in txt_lower: | |
key_center = (item["center_x"], item["center_y"]) | |
key_height = item["height"] | |
best_candidate = None | |
min_dx = float('inf') | |
for other in ocr_list: | |
if other == item: | |
continue | |
if other["center_x"] > key_center[0] and abs(other["center_y"] - key_center[1]) < 0.5 * key_height: | |
dx = other["center_x"] - key_center[0] | |
if dx < min_dx: | |
min_dx = dx | |
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 | |
if extracted: | |
st.write("**Hasil Ekstraksi Key-Value:**") | |
for k, v in extracted.items(): | |
st.write(f"{k}: {v}") | |
else: | |
st.warning("Tidak ditemukan pasangan key-value yang cocok.") | |
# Tampilkan hasil OCR dengan bounding box untuk referensi | |
boxes_ocr = [line["box"] for line in ocr_list] | |
texts_ocr = [line["text"] for line in ocr_list] | |
scores_ocr = [line["score"] for line in ocr_list] | |
im_show = draw_ocr(Image.open(img_path).convert("RGB"), boxes_ocr, texts_ocr, scores_ocr, font_path="simfang.ttf") | |
im_show = Image.fromarray(im_show) | |
st.image(im_show, caption="Hasil OCR dengan Bounding Boxes", use_column_width=True) | |
# --- Koreksi Manual dengan st.form --- | |
with st.form("correction_form"): | |
st.write("Silakan koreksi nilai jika diperlukan (hanya angka, tanpa satuan):") | |
corrected_data = {} | |
for key in target_keys.keys(): | |
key_cap = key.capitalize() | |
current_val = str(parse_numeric_value(extracted.get(key_cap, ""))) if key_cap in extracted else "" | |
new_val = st.text_input(f"{key_cap}", value=current_val) | |
corrected_data[key_cap] = new_val | |
submit_button = st.form_submit_button("Hitung") | |
if submit_button: | |
try: | |
serving_size = parse_numeric_value(corrected_data.get("Takaran saji", "100")) | |
except: | |
serving_size = 0.0 | |
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: | |
sugar_norm = (sugar_value / serving_size) * 100 | |
fat_norm = (fat_value / serving_size) * 100 | |
else: | |
st.error("Takaran saji tidak valid untuk normalisasi.") | |
sugar_norm, fat_norm = sugar_value, fat_value | |
st.write("**Tabel Hasil Normalisasi per 100 g/ml**") | |
data_tabel = { | |
"Nutrisi": ["Gula", "Lemak jenuh"], | |
"Nilai (per 100 g/ml)": [sugar_norm, fat_norm] | |
} | |
df_tabel = pd.DataFrame(data_tabel) | |
st.table(df_tabel) | |
# Hitung Grade | |
def grade_from_value(value, thresholds): | |
if value <= thresholds["A"]: | |
return "Grade A" | |
elif value <= thresholds["B"]: | |
return "Grade B" | |
elif value <= thresholds["C"]: | |
return "Grade C" | |
else: | |
return "Grade D" | |
thresholds_sugar = {"A": 1.0, "B": 5.0, "C": 10.0} | |
thresholds_fat = {"A": 0.7, "B": 1.2, "C": 2.8} | |
sugar_grade = grade_from_value(sugar_norm, thresholds_sugar) | |
fat_grade = grade_from_value(fat_norm, thresholds_fat) | |
grade_scores = {"Grade A": 1, "Grade B": 2, "Grade C": 3, "Grade D": 4} | |
worst_score = max(grade_scores[sugar_grade], grade_scores[fat_grade]) | |
inverse_scores = {v: k for k, v in grade_scores.items()} | |
final_grade = inverse_scores[worst_score] | |
st.write(f"**Grade Gula:** {sugar_grade}") | |
st.write(f"**Grade Lemak Jenuh:** {fat_grade}") | |
st.write(f"**Grade Akhir:** {final_grade}") | |
def color_grade(grade_text): | |
if grade_text == "Grade A": | |
bg_color = "#2ecc71" | |
elif grade_text == "Grade B": | |
bg_color = "#f1c40f" | |
elif grade_text == "Grade C": | |
bg_color = "#e67e22" | |
else: | |
bg_color = "#e74c3c" | |
return f""" | |
<div style=" | |
background-color: {bg_color}; | |
padding: 10px; | |
border-radius: 5px; | |
margin-top: 10px; | |
font-weight: bold; | |
color: white; | |
text-align: center; | |
"> | |
{grade_text} | |
</div> | |
""" | |
st.markdown(color_grade(final_grade), unsafe_allow_html=True) | |
# --- Integrasi Qwen Satu Kali untuk Saran Nutrisi --- | |
nutrition_prompt = f""" | |
Anda adalah ahli gizi yang ramah, komunikatif, dan berpengalaman. | |
Data nutrisi: | |
- Takaran saji: {serving_size} g/ml | |
- Kandungan Gula (per 100 g/ml): {sugar_norm} g | |
- Kandungan Lemak Jenuh (per 100 g/ml): {fat_norm} g | |
- Grade Gula: {sugar_grade} | |
- Grade Lemak Jenuh: {fat_grade} | |
- Grade Akhir: {final_grade} | |
Berdasarkan data tersebut, berikan saran nutrisi yang informatif dalam satu paragraf pendek (50-100 kata). | |
Jelaskan secara ringkas dengan mengulang data nutrisi, dampak kesehatannya, dan berikan tips praktis untuk menjaga pola makan seimbang dengan bahasa yang bersahabat. | |
""" | |
st.write("Tunggu sebentar, Qwen si AI nutritionist sedang memproses penjelasannya... 🤖") | |
try: | |
completion = openai.ChatCompletion.create( | |
model="qwen/qwen2.5-vl-72b-instruct:free", | |
messages=[ | |
{ | |
"role": "user", | |
"content": nutrition_prompt | |
} | |
] | |
) | |
nutrition_advice = completion.choices[0].message.content | |
st.write("**Saran Nutrisi dari Qwen:**") | |
st.write(nutrition_advice) | |
except Exception as e: | |
st.error(f"Gagal mendapatkan saran dari Qwen: {e}") | |
# --- Tampilan Tim Pengembang --- | |
st.markdown(""" | |
<div style="border: 2px solid #007BFF; padding: 10px; border-radius: 8px; margin-top: 20px;"> | |
<h4>Tim Pengembang</h4> | |
<p><strong>Nicholas Dominic</strong>, Mentor - <a href="https://www.linkedin.com/in/nicholas-dominic">LinkedIn</a></p> | |
<p><strong>Tata Aditya Pamungkas</strong>, Machine Learning - <a href="https://www.linkedin.com/in/tata-aditya-pamungkas">LinkedIn</a></p> | |
<p><strong>Raihan Hafiz</strong>, Web Dev - <a href="https://www.linkedin.com/in/m-raihan-hafiz-91a368186">LinkedIn</a></p> | |
</div> <br> | |
""", unsafe_allow_html=True) | |
with st.expander("Ide inovasi kami kedepannya untuk pengembangan"): | |
st.markdown(""" | |
1. Memakai server berbayar agar lebih banyak pengguna yang bisa mengakses. | |
2. Recall asupan berdasarkan makanan real food sehari-hari. Kami sudah berkonsultasi dengan kak Firzah Marhamah [nutritionist](https://www.linkedin.com/in/firza-marhamah) | |
dan ini akan sangat membantu masyarakat untuk mengetahui asupan gizi seimbang. | |
3. Penghitung kalori harian yang terpersonalisasi. | |
""") | |