Spaces:
Build error
Build error
Update app.py
Browse files
app.py
CHANGED
@@ -1,408 +1,249 @@
|
|
1 |
# ==============================================================================
|
2 |
-
# 1
|
3 |
# ==============================================================================
|
4 |
import streamlit as st
|
5 |
import cv2
|
6 |
import numpy as np
|
7 |
import re
|
|
|
8 |
import pandas as pd
|
9 |
from PIL import Image
|
10 |
import time
|
|
|
11 |
from paddleocr import PaddleOCR, draw_ocr
|
12 |
-
import openai
|
13 |
|
14 |
-
#
|
15 |
-
# 2. KONFIGURASI APLIKASI
|
16 |
-
# ==============================================================================
|
17 |
-
# Konfigurasi halaman Streamlit (sebaiknya dipanggil sekali di awal)
|
18 |
st.set_page_config(
|
19 |
-
page_title="Nutri-Grade
|
20 |
-
page_icon="
|
21 |
-
layout="
|
22 |
initial_sidebar_state="auto"
|
23 |
)
|
24 |
|
25 |
-
# ---
|
26 |
-
# Menggunakan st.secrets untuk keamanan, jangan hardcode kunci API!
|
27 |
-
# Buat file .streamlit/secrets.toml di repo Hugging Face Anda.
|
28 |
-
# Isinya:
|
29 |
-
OPENAI_API_KEY = "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"
|
30 |
try:
|
31 |
-
|
32 |
-
except
|
33 |
-
st.error("
|
34 |
st.stop()
|
35 |
|
36 |
-
|
37 |
-
AI_MODEL_NAME = "qwen/qwen2.5-vl-72b-instruct:free"
|
38 |
-
|
39 |
-
# --- Variabel Global dan Konstanta ---
|
40 |
-
TARGET_KEYS = {
|
41 |
-
"gula": ["gula", "sugar"],
|
42 |
-
"takaran saji": ["takaran saji", "serving size"],
|
43 |
-
"lemak jenuh": ["lemak jenuh", "saturated fat"]
|
44 |
-
}
|
45 |
|
46 |
# ==============================================================================
|
47 |
-
#
|
48 |
# ==============================================================================
|
49 |
|
50 |
@st.cache_resource
|
51 |
-
def
|
52 |
-
"""
|
53 |
-
Memuat model
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
|
|
60 |
|
61 |
def parse_numeric_value(text: str) -> float:
|
62 |
-
"""
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
|
|
|
67 |
return 0.0
|
68 |
-
# Mengambil semua digit, titik, dan tanda minus
|
69 |
-
cleaned = re.sub(r"[^\d\.\-]", "", text)
|
70 |
try:
|
71 |
return float(cleaned)
|
72 |
except (ValueError, TypeError):
|
73 |
return 0.0
|
74 |
|
75 |
-
def
|
76 |
-
"""
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
result = ocr_model.ocr(image_path, cls=True)
|
83 |
-
if not result or not result[0]:
|
84 |
-
return []
|
85 |
-
|
86 |
-
ocr_list = []
|
87 |
-
for line in result[0]:
|
88 |
-
box = line[0]
|
89 |
-
text, score = line[1]
|
90 |
-
xs = [pt[0] for pt in box]
|
91 |
-
ys = [pt[1] for pt in box]
|
92 |
-
ocr_list.append({
|
93 |
-
"text": text,
|
94 |
-
"box": box,
|
95 |
-
"score": score,
|
96 |
-
"center_x": sum(xs) / len(xs),
|
97 |
-
"center_y": sum(ys) / len(ys),
|
98 |
-
"height": max(ys) - min(ys)
|
99 |
-
})
|
100 |
-
# Urutkan berdasarkan posisi vertikal (atas ke bawah)
|
101 |
-
return sorted(ocr_list, key=lambda x: x["center_y"])
|
102 |
-
|
103 |
-
def extract_key_values(ocr_data: list, target_keys: dict) -> dict:
|
104 |
-
"""
|
105 |
-
Mengekstrak pasangan key-value dari data OCR yang telah diproses.
|
106 |
-
"""
|
107 |
extracted = {}
|
108 |
-
|
109 |
-
#
|
110 |
-
for item in
|
111 |
-
txt_lower = item["text"].lower()
|
112 |
-
if ":" in txt_lower:
|
113 |
-
parts = txt_lower.split(":", 1)
|
114 |
-
key_candidate, value_candidate = parts[0].strip(), parts[1].strip()
|
115 |
-
|
116 |
-
for canonical, variants in target_keys.items():
|
117 |
-
if canonical.capitalize() not in extracted:
|
118 |
-
for variant in variants:
|
119 |
-
if variant in key_candidate:
|
120 |
-
clean_value = re.sub(r"[^\d\.\-]", "", value_candidate)
|
121 |
-
if clean_value and clean_value != ".":
|
122 |
-
extracted[canonical.capitalize()] = clean_value
|
123 |
-
break
|
124 |
-
|
125 |
-
# Pass 2: Fallback, mencari nilai yang paling dekat di sebelah kanan key
|
126 |
-
for item in ocr_data:
|
127 |
txt_lower = item["text"].lower()
|
128 |
for canonical, variants in target_keys.items():
|
129 |
-
if canonical
|
130 |
for variant in variants:
|
131 |
if variant in txt_lower:
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
is_to_the_right = other["center_x"] > key_center_x
|
140 |
-
|
141 |
-
if item != other and is_aligned_y and is_to_the_right:
|
142 |
-
horizontal_dist = other["center_x"] - key_center_x
|
143 |
-
if horizontal_dist < min_horizontal_dist:
|
144 |
-
min_horizontal_dist = horizontal_dist
|
145 |
-
best_candidate = other
|
146 |
-
|
147 |
-
if best_candidate:
|
148 |
-
raw_value = best_candidate["text"]
|
149 |
-
clean_value = re.sub(r"[^\d\.\-]", "", raw_value)
|
150 |
-
if clean_value and clean_value != ".":
|
151 |
-
extracted[canonical.capitalize()] = clean_value
|
152 |
-
break # Pindah ke canonical key berikutnya
|
153 |
return extracted
|
154 |
|
155 |
-
def
|
156 |
-
"""
|
157 |
-
|
158 |
-
"""
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
sugar_grade = get_grade(sugar_norm, "sugar")
|
172 |
-
fat_grade = get_grade(fat_norm, "fat")
|
173 |
-
|
174 |
worst_score = max(grade_scores[sugar_grade], grade_scores[fat_grade])
|
175 |
final_grade = next(grade for grade, score in grade_scores.items() if score == worst_score)
|
176 |
-
|
177 |
-
return f"Grade {sugar_grade}", f"Grade {fat_grade}", f"Grade {final_grade}"
|
178 |
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
"""
|
183 |
nutrition_prompt = f"""
|
184 |
-
Anda adalah
|
185 |
-
|
186 |
-
-
|
187 |
-
- Kandungan
|
188 |
-
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
|
|
199 |
try:
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
return
|
205 |
except Exception as e:
|
206 |
-
return f"
|
207 |
-
|
208 |
-
def display_colored_grade(grade_text: str):
|
209 |
-
"""
|
210 |
-
Menampilkan grade akhir dengan warna latar yang sesuai.
|
211 |
-
"""
|
212 |
-
color_map = {
|
213 |
-
"Grade A": "#2ecc71", # Hijau
|
214 |
-
"Grade B": "#f1c40f", # Kuning
|
215 |
-
"Grade C": "#e67e22", # Oranye
|
216 |
-
"Grade D": "#e74c3c" # Merah
|
217 |
-
}
|
218 |
-
bg_color = color_map.get(grade_text, "#7f8c8d") # Default abu-abu
|
219 |
-
|
220 |
-
html_code = f"""
|
221 |
-
<div style="
|
222 |
-
background-color: {bg_color};
|
223 |
-
padding: 15px;
|
224 |
-
border-radius: 8px;
|
225 |
-
margin-top: 10px;
|
226 |
-
font-weight: bold;
|
227 |
-
color: white;
|
228 |
-
text-align: center;
|
229 |
-
font-size: 20px;
|
230 |
-
">
|
231 |
-
{grade_text}
|
232 |
-
</div>
|
233 |
-
"""
|
234 |
-
st.markdown(html_code, unsafe_allow_html=True)
|
235 |
|
236 |
# ==============================================================================
|
237 |
-
#
|
238 |
# ==============================================================================
|
239 |
|
240 |
# --- Judul dan Deskripsi ---
|
241 |
-
st.title("
|
242 |
-
st.caption("
|
243 |
|
244 |
-
# --- Petunjuk
|
245 |
-
with st.expander("Petunjuk Penggunaan
|
246 |
-
st.markdown("""
|
247 |
-
1. **Upload Gambar**: Unggah gambar tabel gizi produk. Jika dari ponsel, Anda bisa langsung menggunakan kamera.
|
248 |
-
2. **Deteksi Teks (OCR)**: Sistem akan secara otomatis mendeteksi teks dan angka pada gambar.
|
249 |
-
3. **Koreksi Manual**: Periksa hasil deteksi. Jika ada yang kurang tepat, Anda bisa memperbaikinya di formulir.
|
250 |
-
4. **Hitung Grade**: Klik tombol "Hitung" untuk melihat hasil analisis, grade, dan saran nutrisi.
|
251 |
-
""")
|
252 |
-
|
253 |
-
with st.expander("⚠️ Harap Diperhatikan"):
|
254 |
st.markdown("""
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
- Referensi utama: [Health Promotion Board Singapore](https://www.hpb.gov.sg/docs/default-source/pdf/nutri-grade-ci-guide_eng-only67e4e36349ad4274bfdb22236872336d.pdf).
|
260 |
""")
|
261 |
|
262 |
# --- Inisialisasi Model OCR ---
|
263 |
-
ocr_model =
|
|
|
|
|
|
|
264 |
|
265 |
# --- STEP 1: Upload Gambar ---
|
|
|
266 |
uploaded_file = st.file_uploader(
|
267 |
-
"
|
268 |
type=["jpg", "jpeg", "png"]
|
269 |
)
|
270 |
|
|
|
271 |
if uploaded_file is not None:
|
272 |
-
#
|
273 |
-
|
274 |
-
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
st.error("OCR tidak dapat menemukan teks apapun pada gambar. Coba gambar yang lebih jelas.")
|
297 |
-
st.stop()
|
298 |
-
else:
|
299 |
-
st.success(f"OCR berhasil! Ditemukan {len(st.session_state.ocr_data)} baris teks dalam {ocr_time:.2f} detik.")
|
300 |
-
st.session_state.extracted_data = extract_key_values(st.session_state.ocr_data, TARGET_KEYS)
|
301 |
-
|
302 |
-
# Tampilkan hasil OCR dengan bounding box untuk referensi
|
303 |
-
with st.expander("Lihat Hasil Deteksi Teks (OCR)"):
|
304 |
-
boxes_ocr = [line["box"] for line in st.session_state.ocr_data]
|
305 |
-
texts_ocr = [line["text"] for line in st.session_state.ocr_data]
|
306 |
-
scores_ocr = [line["score"] for line in st.session_state.ocr_data]
|
307 |
-
# Gunakan font default jika simfang tidak ada
|
308 |
-
try:
|
309 |
-
im_show = draw_ocr(Image.open(img_path).convert("RGB"), boxes_ocr, texts_ocr, scores_ocr, font_path="simfang.ttf")
|
310 |
-
except:
|
311 |
-
im_show = draw_ocr(Image.open(img_path).convert("RGB"), boxes_ocr, texts_ocr, scores_ocr)
|
312 |
-
im_show = Image.fromarray(im_show)
|
313 |
-
st.image(im_show, caption="Hasil OCR dengan Bounding Boxes", use_column_width=True)
|
314 |
-
|
315 |
-
# --- STEP 3: Koreksi Manual ---
|
316 |
-
st.markdown("---")
|
317 |
-
st.subheader("Verifikasi & Koreksi Data")
|
318 |
-
st.info("Periksa dan koreksi nilai yang diekstrak jika perlu. Masukkan **hanya angka** (gunakan titik untuk desimal).")
|
319 |
-
|
320 |
with st.form("correction_form"):
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
label=f"**{key_cap}** (angka saja)",
|
331 |
-
value=default_val
|
332 |
-
)
|
333 |
-
|
334 |
-
submit_button = st.form_submit_button("✅ Hitung Grade & Dapatkan Saran")
|
335 |
-
|
336 |
-
# --- STEP 4: Kalkulasi dan Tampilan Hasil ---
|
337 |
if submit_button:
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
fat_value = parse_numeric_value(corrected_data.get("Lemak jenuh", "0"))
|
343 |
-
|
344 |
-
if serving_size <= 0:
|
345 |
-
st.error("Takaran Saji harus lebih besar dari nol untuk melakukan normalisasi.")
|
346 |
-
st.stop()
|
347 |
-
|
348 |
-
# Normalisasi ke per 100g/ml
|
349 |
-
sugar_norm = (sugar_value / serving_size) * 100
|
350 |
-
fat_norm = (fat_value / serving_size) * 100
|
351 |
-
|
352 |
-
# Hitung Grade
|
353 |
-
sugar_grade, fat_grade, final_grade = calculate_final_grade(sugar_norm, fat_norm)
|
354 |
-
|
355 |
-
st.markdown("---")
|
356 |
-
st.subheader("Hasil Analisis Nutrisi")
|
357 |
-
|
358 |
-
col1, col2 = st.columns(2)
|
359 |
-
with col1:
|
360 |
-
st.write("**Hasil Normalisasi per 100 g/ml**")
|
361 |
-
df_tabel = pd.DataFrame({
|
362 |
-
"Nutrisi": ["Gula Total", "Lemak Jenuh"],
|
363 |
-
"Nilai (per 100 g/ml)": [f"{sugar_norm:.2f} g", f"{fat_norm:.2f} g"]
|
364 |
-
})
|
365 |
-
st.table(df_tabel)
|
366 |
-
|
367 |
-
with col2:
|
368 |
-
st.write("**Hasil Penilaian Grade**")
|
369 |
-
st.metric(label="Grade Gula", value=sugar_grade)
|
370 |
-
st.metric(label="Grade Lemak Jenuh", value=fat_grade)
|
371 |
-
|
372 |
-
st.write("**Grade Akhir Produk**")
|
373 |
-
display_colored_grade(final_grade)
|
374 |
-
|
375 |
-
st.markdown("---")
|
376 |
-
st.subheader("Saran dari Ahli Gizi AI")
|
377 |
-
|
378 |
-
advice_data = {
|
379 |
-
"serving_size": serving_size, "sugar_norm": sugar_norm, "fat_norm": fat_norm,
|
380 |
-
"sugar_grade": sugar_grade, "fat_grade": fat_grade, "final_grade": final_grade
|
381 |
-
}
|
382 |
-
nutrition_advice = generate_nutrition_advice(advice_data)
|
383 |
-
st.success(nutrition_advice)
|
384 |
-
|
385 |
-
except Exception as e:
|
386 |
-
st.error(f"Terjadi kesalahan saat perhitungan: {e}")
|
387 |
|
388 |
-
|
389 |
-
|
390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
391 |
st.markdown("---")
|
392 |
-
|
393 |
-
# --- Tampilan Tim Pengembang ---
|
394 |
-
st.markdown("""
|
395 |
-
<div style="border: 1px solid #dfe6e9; padding: 15px; border-radius: 10px; margin-top: 20px; background-color: #fafafa;">
|
396 |
-
<h4 style="text-align: center; color: #007BFF;">Tim Pengembang</h4>
|
397 |
-
<p><strong>Nicholas Dominic</strong>, Mentor - <a href="https://www.linkedin.com/in/nicholas-dominic" target="_blank">LinkedIn</a></p>
|
398 |
-
<p><strong>Tata Aditya Pamungkas</strong>, Machine Learning - <a href="https://www.linkedin.com/in/tata-aditya-pamungkas" target="_blank">LinkedIn</a></p>
|
399 |
-
<p><strong>Raihan Hafiz</strong>, Web Dev - <a href="https://www.linkedin.com/in/m-raihan-hafiz-91a368186" target="_blank">LinkedIn</a></p>
|
400 |
-
</div>
|
401 |
-
""", unsafe_allow_html=True)
|
402 |
-
|
403 |
-
with st.expander("Rencana Pengembangan & Inovasi Selanjutnya 🚀"):
|
404 |
-
st.markdown("""
|
405 |
-
1. **Infrastruktur yang Lebih Baik**: Migrasi ke server berbayar untuk meningkatkan kecepatan, stabilitas, dan kapasitas pengguna.
|
406 |
-
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).
|
407 |
-
3. **Kalkulator Kalori Harian**: Menambahkan fitur penghitung kebutuhan kalori harian yang dipersonalisasi berdasarkan data pengguna (usia, berat badan, tinggi badan, tingkat aktivitas).
|
408 |
-
""")
|
|
|
1 |
# ==============================================================================
|
2 |
+
# BAGIAN 1: IMPORT LIBRARY & KONFIGURASI AWAL
|
3 |
# ==============================================================================
|
4 |
import streamlit as st
|
5 |
import cv2
|
6 |
import numpy as np
|
7 |
import re
|
8 |
+
import os
|
9 |
import pandas as pd
|
10 |
from PIL import Image
|
11 |
import time
|
12 |
+
import requests
|
13 |
from paddleocr import PaddleOCR, draw_ocr
|
|
|
14 |
|
15 |
+
# Konfigurasi halaman Streamlit
|
|
|
|
|
|
|
16 |
st.set_page_config(
|
17 |
+
page_title="Nutri-Grade Label Detection",
|
18 |
+
page_icon="🥗",
|
19 |
+
layout="wide",
|
20 |
initial_sidebar_state="auto"
|
21 |
)
|
22 |
|
23 |
+
# --- Memuat API Key dengan aman menggunakan st.secrets ---
|
|
|
|
|
|
|
|
|
24 |
try:
|
25 |
+
OPENROUTER_API_KEY = st.secrets["OPENROUTER_API_KEY"]
|
26 |
+
except KeyError:
|
27 |
+
st.error("API Key (OPENROUTER_API_KEY) tidak ditemukan. Harap buat file .streamlit/secrets.toml.")
|
28 |
st.stop()
|
29 |
|
30 |
+
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
|
32 |
# ==============================================================================
|
33 |
+
# BAGIAN 2: FUNGSI-FUNGSI BANTUAN (HELPER FUNCTIONS)
|
34 |
# ==============================================================================
|
35 |
|
36 |
@st.cache_resource
|
37 |
+
def initialize_ocr():
|
38 |
+
"""Memuat model PaddleOCR ke dalam cache agar tidak dimuat berulang kali."""
|
39 |
+
st.info("Pertama kali dijalankan: Memuat model OCR. Mohon tunggu sebentar...")
|
40 |
+
# Langsung gunakan CPU mode (use_gpu=False) untuk stabilitas maksimum di server gratis.
|
41 |
+
try:
|
42 |
+
ocr = PaddleOCR(use_gpu=False, lang='en', show_log=False)
|
43 |
+
return ocr
|
44 |
+
except Exception as e:
|
45 |
+
st.error(f"Gagal total memuat model OCR: {e}")
|
46 |
+
return None
|
47 |
|
48 |
def parse_numeric_value(text: str) -> float:
|
49 |
+
"""Membersihkan string dan mengubahnya menjadi angka float yang valid."""
|
50 |
+
if not text:
|
51 |
+
return 0.0
|
52 |
+
# Mengambil hanya digit dan titik desimal
|
53 |
+
cleaned = re.sub(r"[^\d\.]", "", str(text))
|
54 |
+
if not cleaned or cleaned == ".":
|
55 |
return 0.0
|
|
|
|
|
56 |
try:
|
57 |
return float(cleaned)
|
58 |
except (ValueError, TypeError):
|
59 |
return 0.0
|
60 |
|
61 |
+
def extract_nutrition_data(ocr_list: list) -> dict:
|
62 |
+
"""Mengekstrak data Gula, Lemak Jenuh, dan Takaran Saji dari hasil OCR."""
|
63 |
+
target_keys = {
|
64 |
+
"Takaran saji": ["takaran saji", "serving size", "per serving", "sajian"],
|
65 |
+
"Gula": ["gula", "sugar", "sugars", "total sugar"],
|
66 |
+
"Lemak jenuh": ["lemak jenuh", "saturated fat", "saturated", "sat fat"]
|
67 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
68 |
extracted = {}
|
69 |
+
|
70 |
+
# Mencari nilai berdasarkan kata kunci yang paling relevan
|
71 |
+
for item in ocr_list:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
72 |
txt_lower = item["text"].lower()
|
73 |
for canonical, variants in target_keys.items():
|
74 |
+
if canonical not in extracted:
|
75 |
for variant in variants:
|
76 |
if variant in txt_lower:
|
77 |
+
# Mencari angka dalam baris teks yang sama
|
78 |
+
found_numbers = re.findall(r'(\d+\.?\d*)', txt_lower)
|
79 |
+
if found_numbers:
|
80 |
+
extracted[canonical] = found_numbers[0]
|
81 |
+
break # Lanjut ke kata kunci berikutnya
|
82 |
+
if canonical in extracted:
|
83 |
+
break # Lanjut ke baris teks berikutnya
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
84 |
return extracted
|
85 |
|
86 |
+
def calculate_grades(sugar_norm: float, fat_norm: float) -> tuple[str, str, str]:
|
87 |
+
"""Menghitung grade A, B, C, atau D berdasarkan nilai ternormalisasi."""
|
88 |
+
thresholds_sugar = {"A": 1.0, "B": 5.0, "C": 10.0}
|
89 |
+
thresholds_fat = {"A": 0.7, "B": 1.2, "C": 2.8}
|
90 |
+
|
91 |
+
def get_grade(value, thresholds):
|
92 |
+
if value <= thresholds["A"]: return "Grade A"
|
93 |
+
if value <= thresholds["B"]: return "Grade B"
|
94 |
+
if value <= thresholds["C"]: return "Grade C"
|
95 |
+
return "Grade D"
|
96 |
+
|
97 |
+
sugar_grade = get_grade(sugar_norm, thresholds_sugar)
|
98 |
+
fat_grade = get_grade(fat_norm, thresholds_fat)
|
99 |
+
|
100 |
+
grade_scores = {"Grade A": 1, "Grade B": 2, "Grade C": 3, "Grade D": 4}
|
|
|
|
|
|
|
|
|
101 |
worst_score = max(grade_scores[sugar_grade], grade_scores[fat_grade])
|
102 |
final_grade = next(grade for grade, score in grade_scores.items() if score == worst_score)
|
|
|
|
|
103 |
|
104 |
+
return sugar_grade, fat_grade, final_grade
|
105 |
+
|
106 |
+
def get_nutrition_advice(serving_size, sugar_norm, fat_norm, sugar_grade, fat_grade, final_grade):
|
107 |
+
"""Mengirim request ke OpenRouter API untuk mendapatkan saran nutrisi dari AI."""
|
108 |
nutrition_prompt = f"""
|
109 |
+
Anda adalah ahli gizi dari Indonesia yang ramah dan suportif.
|
110 |
+
Data nutrisi produk:
|
111 |
+
- Kandungan Gula (per 100g): {sugar_norm:.1f}g (Grade: {sugar_grade})
|
112 |
+
- Kandungan Lemak Jenuh (per 100g): {fat_norm:.1f}g (Grade: {fat_grade})
|
113 |
+
- Grade Akhir Produk: {final_grade}
|
114 |
+
|
115 |
+
Tugas: Berikan saran nutrisi yang singkat, positif, dan actionable dalam 1-2 kalimat.
|
116 |
+
Fokus pada bagaimana cara menikmati produk ini secara bijak dalam pola makan seimbang.
|
117 |
+
Gunakan bahasa yang mudah dimengerti.
|
118 |
+
"""
|
119 |
+
headers = {"Authorization": f"Bearer {OPENROUTER_API_KEY}"}
|
120 |
+
payload = {
|
121 |
+
"model": "qwen/qwen2.5-vl-72b-instruct:free",
|
122 |
+
"messages": [{"role": "user", "content": nutrition_prompt}],
|
123 |
+
"max_tokens": 150, "temperature": 0.7
|
124 |
+
}
|
125 |
try:
|
126 |
+
response = requests.post(f"{OPENROUTER_BASE_URL}/chat/completions", headers=headers, json=payload, timeout=30)
|
127 |
+
response.raise_for_status() # Ini akan memicu error jika status code bukan 2xx
|
128 |
+
return response.json()["choices"][0]["message"]["content"]
|
129 |
+
except requests.exceptions.RequestException as e:
|
130 |
+
return f"Error API: Gagal terhubung ke server. {e}"
|
131 |
except Exception as e:
|
132 |
+
return f"Error: Terjadi kesalahan saat memproses respons AI. {e}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
133 |
|
134 |
# ==============================================================================
|
135 |
+
# BAGIAN 3: TAMPILAN UTAMA APLIKASI (USER INTERFACE)
|
136 |
# ==============================================================================
|
137 |
|
138 |
# --- Judul dan Deskripsi ---
|
139 |
+
st.title("🥗 Kalkulator Nutri-Grade")
|
140 |
+
st.caption("Unggah foto label gizi untuk menganalisis kandungan gula & lemak jenuhnya.")
|
141 |
|
142 |
+
# --- Petunjuk & Informasi ---
|
143 |
+
with st.expander("📋 Lihat Petunjuk Penggunaan"):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
144 |
st.markdown("""
|
145 |
+
1. **Unggah Gambar**: Pilih atau ambil foto label gizi produk.
|
146 |
+
2. **Analisis Otomatis**: Sistem akan langsung mendeteksi teks dan angka.
|
147 |
+
3. **Koreksi Data**: Periksa angka yang diekstrak. Perbaiki jika ada yang salah di formulir.
|
148 |
+
4. **Hitung & Lihat Hasil**: Klik tombol "Hitung Grade" untuk melihat hasilnya.
|
|
|
149 |
""")
|
150 |
|
151 |
# --- Inisialisasi Model OCR ---
|
152 |
+
ocr_model = initialize_ocr()
|
153 |
+
if ocr_model is None:
|
154 |
+
st.error("Model OCR tidak dapat dimuat. Aplikasi tidak bisa melanjutkan.")
|
155 |
+
st.stop()
|
156 |
|
157 |
# --- STEP 1: Upload Gambar ---
|
158 |
+
st.header("1. Unggah Gambar Label Gizi")
|
159 |
uploaded_file = st.file_uploader(
|
160 |
+
"Pilih gambar (JPG/PNG/JPEG)",
|
161 |
type=["jpg", "jpeg", "png"]
|
162 |
)
|
163 |
|
164 |
+
# Alur akan berjalan jika ada file yang diunggah
|
165 |
if uploaded_file is not None:
|
166 |
+
# Baca dan tampilkan gambar
|
167 |
+
file_bytes = np.asarray(bytearray(uploaded_file.read()), dtype=np.uint8)
|
168 |
+
img = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)
|
169 |
+
st.image(cv2.cvtColor(img, cv2.COLOR_BGR2RGB), caption="Gambar yang diunggah", use_column_width=True)
|
170 |
+
|
171 |
+
# --- STEP 2: Analisis OCR Otomatis ---
|
172 |
+
with st.spinner("Menganalisis teks pada gambar..."):
|
173 |
+
temp_img_path = "temp_uploaded_image.jpg"
|
174 |
+
cv2.imwrite(temp_img_path, img)
|
175 |
+
ocr_result = ocr_model.ocr(temp_img_path, cls=True)
|
176 |
+
|
177 |
+
if not ocr_result or not ocr_result[0]:
|
178 |
+
st.error("Tidak ada teks yang dapat dideteksi. Coba gunakan gambar yang lebih jelas.")
|
179 |
+
st.stop()
|
180 |
+
|
181 |
+
# Proses dan ekstrak data dari hasil OCR
|
182 |
+
ocr_list = [{"text": line[1][0], "box": line[0]} for line in ocr_result[0]]
|
183 |
+
extracted_data = extract_nutrition_data(ocr_list)
|
184 |
+
|
185 |
+
st.success("Analisis OCR selesai! Data berikut berhasil diekstrak:")
|
186 |
+
st.json(extracted_data) # Tampilkan data yang diekstrak dalam format JSON yang rapi
|
187 |
+
|
188 |
+
# --- STEP 3: Formulir Koreksi dan Perhitungan ---
|
189 |
+
st.header("2. Verifikasi Data & Hitung Grade")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
190 |
with st.form("correction_form"):
|
191 |
+
st.caption("Periksa kembali angka di bawah ini. Koreksi jika perlu, lalu klik hitung.")
|
192 |
+
col1, col2, col3 = st.columns(3)
|
193 |
+
|
194 |
+
takaran_saji = col1.text_input("Takaran Saji (g/ml)", value=extracted_data.get("Takaran saji", "100"))
|
195 |
+
gula = col2.text_input("Gula (g)", value=extracted_data.get("Gula", "0"))
|
196 |
+
lemak_jenuh = col3.text_input("Lemak Jenuh (g)", value=extracted_data.get("Lemak jenuh", "0"))
|
197 |
+
|
198 |
+
submit_button = st.form_submit_button("🧮 Hitung Grade", type="primary", use_container_width=True)
|
199 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
200 |
if submit_button:
|
201 |
+
# Konversi input ke angka
|
202 |
+
serving_size_val = parse_numeric_value(takaran_saji)
|
203 |
+
sugar_val = parse_numeric_value(gula)
|
204 |
+
fat_val = parse_numeric_value(lemak_jenuh)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
205 |
|
206 |
+
if serving_size_val <= 0:
|
207 |
+
st.error("Nilai 'Takaran Saji' harus lebih dari nol.")
|
208 |
+
else:
|
209 |
+
# Normalisasi nilai nutrisi ke per 100g/ml
|
210 |
+
sugar_norm = (sugar_val / serving_size_val) * 100
|
211 |
+
fat_norm = (fat_val / serving_size_val) * 100
|
212 |
+
|
213 |
+
# Hitung grade
|
214 |
+
sugar_grade, fat_grade, final_grade = calculate_grades(sugar_norm, fat_norm)
|
215 |
+
|
216 |
+
# --- STEP 4: Tampilkan Hasil ---
|
217 |
+
st.header("🏆 Hasil Analisis Anda")
|
218 |
+
col1, col2 = st.columns([1, 2]) # Buat kolom dengan rasio lebar 1:2
|
219 |
+
|
220 |
+
with col1: # Kolom Kiri untuk Grade Akhir
|
221 |
+
color_map = {"A": "#2ecc71", "B": "#f1c40f", "C": "#e67e22", "D": "#e74c3c"}
|
222 |
+
grade_letter = final_grade.split(" ")[-1]
|
223 |
+
bg_color = color_map.get(grade_letter, "#7f8c8d")
|
224 |
+
st.markdown(f"""
|
225 |
+
<div style="background-color: {bg_color}; padding: 20px; border-radius: 10px; text-align: center; color: white;">
|
226 |
+
<p style="font-size: 1.2em; margin:0;">Grade Akhir</p>
|
227 |
+
<h1 style="font-size: 4em; margin:0; color: white;">{grade_letter}</h1>
|
228 |
+
</div>
|
229 |
+
""", unsafe_allow_html=True)
|
230 |
+
|
231 |
+
with col2: # Kolom Kanan untuk detail
|
232 |
+
st.metric(label=f"Kandungan Gula (per 100g)", value=f"{sugar_norm:.1f} g", delta=sugar_grade)
|
233 |
+
st.metric(label=f"Kandungan Lemak Jenuh (per 100g)", value=f"{fat_norm:.1f} g", delta=fat_grade)
|
234 |
+
|
235 |
+
# --- STEP 5: Tampilkan Saran AI ---
|
236 |
+
with st.spinner("Meminta saran dari Ahli Gizi AI..."):
|
237 |
+
advice = get_nutrition_advice(serving_size_val, sugar_norm, fat_norm, sugar_grade, fat_grade, final_grade)
|
238 |
+
if "Error" in advice:
|
239 |
+
st.warning(advice)
|
240 |
+
else:
|
241 |
+
st.success(f"💡 **Saran Ahli Gizi AI**: {advice}")
|
242 |
+
|
243 |
+
# Hapus file gambar sementara setelah selesai
|
244 |
+
if os.path.exists(temp_img_path):
|
245 |
+
os.remove(temp_img_path)
|
246 |
+
|
247 |
+
# --- Footer dan Informasi Tambahan ---
|
248 |
st.markdown("---")
|
249 |
+
st.markdown("Dibuat oleh Tim Nutri-Grade dengan Streamlit.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|