YourAIEngineer commited on
Commit
b859d45
·
verified ·
1 Parent(s): 094a7f2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +359 -763
app.py CHANGED
@@ -1,812 +1,408 @@
 
 
 
1
  import streamlit as st
2
  import cv2
3
  import numpy as np
4
  import re
5
- import os
6
  import pandas as pd
7
  from PIL import Image
8
  import time
9
- import requests
10
- import json
11
  from paddleocr import PaddleOCR, draw_ocr
 
12
 
13
- # Konfigurasi OpenRouter API
14
- OPENROUTER_API_KEY = "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"
15
- OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
16
-
17
- # Konfigurasi Streamlit
18
  st.set_page_config(
19
- page_title="Nutri-Grade Label Detection",
20
- page_icon="🥗",
21
- layout="wide",
22
- initial_sidebar_state="collapsed"
23
  )
24
 
25
- # Title dan Deskripsi
26
- st.title("🥗 Nutri-Grade Label Detection & Grade Calculator")
27
- st.caption("Selamat Datang di aplikasi prototype kami. Terinspirasi dari NutriGrade Singapura, Aplikasi ini berguna untuk memberi label berdasarkan tabel gizi produk. Kami berharap aplikasi ini dapat membantu teman-teman dalam memilih produk makanan yang lebih sehat.")
 
 
 
 
 
 
 
28
 
29
- # -----------------------------------------------
30
- # Info & Petunjuk Penggunaan
31
- # -----------------------------------------------
32
- with st.expander("📋 Petunjuk Penggunaan"):
33
- st.markdown("""
34
- **Cara Penggunaan:**
35
- 1. Upload gambar tabel gizi, jika menggunakan smartphone pilih kamera lalu ambil foto.
36
- 2. Sistem mendeteksi teks pada gambar menggunakan OCR.
37
- 3. Periksa dan koreksi nilai secara manual jika diperlukan.
38
- 4. Klik *Hitung* untuk melihat tabel normalisasi, grade, dan saran nutrisi.
39
- """)
40
-
41
- with st.expander("⚠️ Tolong Diperhatikan !!"):
42
- st.markdown("""
43
- 1. Aplikasi ini masih dalam Pengembangan.
44
- 2. Hasil ekstraksi hanya sebagai gambaran; silakan koreksi bila diperlukan.
45
- 3. Hosting gratisan, jadi mungkin ada beberapa kendala.
46
- 4. Kode dapat diakses di Hugging Face untuk kontribusi atau feedback.
47
- 5. Referensi: [Health Promotion Board Singapura](https://www.hpb.gov.sg/docs/default-source/pdf/nutri-grade-ci-guide_eng-only67e4e36349ad4274bfdb22236872336d.pdf)
48
- """)
49
 
50
- # Cache untuk inisialisasi OCR model
51
  @st.cache_resource
52
- def initialize_ocr():
53
- """Initialize PaddleOCR model dengan error handling yang lebih baik"""
54
- try:
55
- # Coba inisialisasi dengan GPU terlebih dahulu
56
- ocr = PaddleOCR(use_gpu=False, lang='en', cls=True,
57
- use_angle_cls=True, use_space_char=True,
58
- show_log=False)
59
- return ocr, "OCR berhasil diinisialisasi"
60
- except Exception as e:
61
- st.warning(f"Gagal inisialisasi OCR dengan GPU: {e}")
62
- try:
63
- # Fallback ke CPU
64
- ocr = PaddleOCR(use_gpu=False, lang='en', cls=False,
65
- use_angle_cls=False, use_space_char=True,
66
- show_log=False)
67
- return ocr, "OCR berhasil diinisialisasi (CPU mode)"
68
- except Exception as e2:
69
- st.error(f"Gagal inisialisasi OCR: {e2}")
70
- return None, f"Error: {e2}"
71
-
72
- # Fungsi untuk membersihkan nilai numerik
73
- def parse_numeric_value(text):
74
- """Parse nilai numerik dari string (contoh: '15g' → 15.0)"""
75
- if not text:
76
- return 0.0
77
-
78
- # Hapus semua karakter non-digit kecuali titik dan minus
79
- cleaned = re.sub(r"[^\d\.\-]", "", str(text))
80
-
81
- # Handle kasus khusus
82
- if not cleaned or cleaned == "." or cleaned == "-":
83
  return 0.0
84
-
 
85
  try:
86
  return float(cleaned)
87
  except (ValueError, TypeError):
88
  return 0.0
89
 
90
- # Fungsi untuk memanggil API OpenRouter
91
- def get_nutrition_advice(serving_size, sugar_norm, fat_norm, sugar_grade, fat_grade, final_grade):
92
- """Mendapatkan saran nutrisi dari Qwen melalui OpenRouter API"""
93
-
94
- nutrition_prompt = f"""
95
- Anda adalah ahli gizi yang ramah, komunikatif, dan berpengalaman.
96
- Data nutrisi:
97
- - Takaran saji: {serving_size} g/ml
98
- - Kandungan Gula (per 100 g/ml): {sugar_norm:.2f} g
99
- - Kandungan Lemak Jenuh (per 100 g/ml): {fat_norm:.2f} g
100
- - Grade Gula: {sugar_grade}
101
- - Grade Lemak Jenuh: {fat_grade}
102
- - Grade Akhir: {final_grade}
103
-
104
- Berdasarkan data tersebut, berikan saran nutrisi yang informatif dalam satu paragraf pendek (50-100 kata).
105
- Jelaskan secara ringkas dengan mengulang data nutrisi, dampak kesehatannya, dan berikan tips praktis untuk menjaga pola makan seimbang dengan bahasa yang bersahabat.
106
  """
107
-
108
- headers = {
109
- "Authorization": f"Bearer {OPENROUTER_API_KEY}",
110
- "Content-Type": "application/json"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  }
 
112
 
113
- payload = {
114
- "model": "qwen/qwen2.5-vl-72b-instruct:free",
115
- "messages": [
116
- {
117
- "role": "user",
118
- "content": nutrition_prompt
119
- }
120
- ],
121
- "max_tokens": 200,
122
- "temperature": 0.7
123
- }
124
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  try:
126
- response = requests.post(
127
- f"{OPENROUTER_BASE_URL}/chat/completions",
128
- headers=headers,
129
- json=payload,
130
- timeout=30
131
  )
132
-
133
- if response.status_code == 200:
134
- data = response.json()
135
- return data["choices"][0]["message"]["content"]
136
- else:
137
- return f"Error: HTTP {response.status_code} - {response.text}"
138
-
139
- except requests.exceptions.Timeout:
140
- return "Error: Request timeout. Silakan coba lagi."
141
- except requests.exceptions.RequestException as e:
142
- return f"Error: {str(e)}"
143
  except Exception as e:
144
- return f"Error: {str(e)}"
145
-
146
- # Inisialisasi OCR model
147
- if 'ocr_model' not in st.session_state:
148
- with st.spinner("Menginisialisasi model OCR..."):
149
- ocr_model, init_message = initialize_ocr()
150
- st.session_state.ocr_model = ocr_model
151
- if ocr_model is not None:
152
- st.success(init_message)
153
- else:
154
- st.error(init_message)
155
- st.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  # --- STEP 1: Upload Gambar ---
158
- st.header("📸 Upload Gambar Tabel Gizi")
159
  uploaded_file = st.file_uploader(
160
- "Pilih gambar (JPG/PNG/JPEG)",
161
- type=["jpg", "jpeg", "png"],
162
- help="Upload gambar tabel gizi untuk dianalisis"
163
  )
164
 
165
  if uploaded_file is not None:
166
- try:
167
- # Baca gambar
168
- file_bytes = np.asarray(bytearray(uploaded_file.read()), dtype=np.uint8)
169
- img = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
 
171
- if img is None:
172
- st.error("Gagal membaca gambar. Pastikan format file benar.")
173
  st.stop()
174
-
175
- # Tampilkan gambar
176
- st.image(cv2.cvtColor(img, cv2.COLOR_BGR2RGB),
177
- caption="Gambar yang diupload",
178
- use_column_width=True)
179
-
180
- # Simpan gambar sementara
181
- temp_img_path = "temp_uploaded_image.jpg"
182
- cv2.imwrite(temp_img_path, img)
183
-
184
- # --- STEP 2: OCR pada Gambar ---
185
- st.header("🔍 Proses OCR")
186
-
187
- if st.button("Mulai Analisis OCR", type="primary"):
188
- with st.spinner("Melakukan OCR pada gambar..."):
189
- start_time = time.time()
190
- try:
191
- ocr_result = st.session_state.ocr_model.ocr(temp_img_path, cls=True)
192
- ocr_time = time.time() - start_time
193
- st.success(f"OCR selesai dalam {ocr_time:.2f} detik")
194
-
195
- if not ocr_result or not ocr_result[0]:
196
- st.error("OCR tidak menemukan teks pada gambar!")
197
- st.stop()
198
-
199
- # Ekstrak data OCR
200
- ocr_data = ocr_result[0]
201
- ocr_list = []
202
-
203
- for line in ocr_data:
204
- if len(line) >= 2:
205
- box = line[0]
206
- text = line[1][0] if len(line[1]) >= 1 else ""
207
- score = line[1][1] if len(line[1]) >= 2 else 0.0
208
-
209
- if box and len(box) >= 4:
210
- xs = [pt[0] for pt in box]
211
- ys = [pt[1] for pt in box]
212
- center_x = sum(xs) / len(xs)
213
- center_y = sum(ys) / len(ys)
214
-
215
- ocr_list.append({
216
- "text": text,
217
- "box": box,
218
- "score": score,
219
- "center_x": center_x,
220
- "center_y": center_y,
221
- "height": max(ys) - min(ys)
222
- })
223
-
224
- # Sort berdasarkan posisi vertikal
225
- ocr_list = sorted(ocr_list, key=lambda x: x["center_y"])
226
-
227
- # Target keys untuk ekstraksi
228
- target_keys = {
229
- "gula": ["gula", "sugar", "sugars", "total sugar"],
230
- "takaran saji": ["takaran saji", "serving size", "per serving", "sajian"],
231
- "lemak jenuh": ["lemak jenuh", "saturated fat", "saturated", "sat fat"]
232
- }
233
-
234
- extracted = {}
235
-
236
- # Pass 1: Ekstraksi dengan tanda titik dua
237
- for item in ocr_list:
238
- txt_lower = item["text"].lower()
239
- if ":" in txt_lower:
240
- parts = txt_lower.split(":")
241
- if len(parts) >= 2:
242
- key_candidate = parts[0].strip()
243
- value_candidate = parts[-1].strip()
244
-
245
- for canonical, variants in target_keys.items():
246
- if canonical not in extracted:
247
- for variant in variants:
248
- if variant in key_candidate:
249
- clean_value = re.sub(r"[^\d\.\-]", "", value_candidate)
250
- if clean_value and clean_value not in ["", "."]:
251
- extracted[canonical.capitalize()] = clean_value
252
- break
253
-
254
- # Pass 2: Fallback untuk key yang belum diekstrak
255
- for item in ocr_list:
256
- txt_lower = item["text"].lower()
257
- for canonical, variants in target_keys.items():
258
- if canonical.capitalize() not in extracted:
259
- for variant in variants:
260
- if variant in txt_lower:
261
- key_center = (item["center_x"], item["center_y"])
262
- key_height = item["height"]
263
- best_candidate = None
264
- min_dx = float('inf')
265
-
266
- # Cari nilai di sebelah kanan
267
- for other in ocr_list:
268
- if other == item:
269
- continue
270
- if (other["center_x"] > key_center[0] and
271
- abs(other["center_y"] - key_center[1]) < 0.5 * key_height):
272
- dx = other["center_x"] - key_center[0]
273
- if dx < min_dx:
274
- min_dx = dx
275
- best_candidate = other
276
-
277
- if best_candidate:
278
- raw_value = best_candidate["text"]
279
- clean_value = re.sub(r"[^\d\.\-]", "", raw_value)
280
- if clean_value and clean_value not in ["", "."]:
281
- extracted[canonical.capitalize()] = clean_value
282
- break
283
-
284
- # Tampilkan hasil ekstraksi
285
- if extracted:
286
- st.subheader("📊 Hasil Ekstraksi")
287
- col1, col2 = st.columns(2)
288
-
289
- with col1:
290
- st.write("**Data yang ditemukan:**")
291
- for k, v in extracted.items():
292
- st.write(f"• {k}: {v}")
293
-
294
- with col2:
295
- # Tampilkan gambar dengan bounding box
296
- try:
297
- boxes_ocr = [line["box"] for line in ocr_list]
298
- texts_ocr = [line["text"] for line in ocr_list]
299
- scores_ocr = [line["score"] for line in ocr_list]
300
-
301
- # Gunakan font default jika simfang.ttf tidak tersedia
302
- try:
303
- im_show = draw_ocr(
304
- Image.open(temp_img_path).convert("RGB"),
305
- boxes_ocr, texts_ocr, scores_ocr,
306
- font_path=None
307
- )
308
- except:
309
- im_show = draw_ocr(
310
- Image.open(temp_img_path).convert("RGB"),
311
- boxes_ocr, texts_ocr, scores_ocr
312
- )
313
-
314
- im_show = Image.fromarray(im_show)
315
- st.image(im_show, caption="Hasil OCR", use_column_width=True)
316
- except Exception as e:
317
- st.warning(f"Tidak dapat menampilkan bounding box: {e}")
318
- else:
319
- st.warning("Tidak ditemukan data nutrisi yang cocok. Silakan input manual.")
320
- extracted = {}
321
-
322
- # --- STEP 3: Koreksi Manual ---
323
- st.header("✏️ Koreksi Data")
324
-
325
- with st.form("correction_form"):
326
- st.write("Silakan koreksi nilai jika diperlukan (hanya angka, tanpa satuan):")
327
-
328
- col1, col2, col3 = st.columns(3)
329
-
330
- with col1:
331
- takaran_saji_val = str(parse_numeric_value(
332
- extracted.get("Takaran saji", "100")
333
- )) if "Takaran saji" in extracted else "100"
334
- takaran_saji = st.text_input(
335
- "Takaran Saji (g/ml)",
336
- value=takaran_saji_val,
337
- help="Masukkan takaran saji dalam gram atau ml"
338
- )
339
-
340
- with col2:
341
- gula_val = str(parse_numeric_value(
342
- extracted.get("Gula", "0")
343
- )) if "Gula" in extracted else ""
344
- gula = st.text_input(
345
- "Gula (g)",
346
- value=gula_val,
347
- help="Masukkan kandungan gula dalam gram"
348
- )
349
-
350
- with col3:
351
- lemak_jenuh_val = str(parse_numeric_value(
352
- extracted.get("Lemak jenuh", "0")
353
- )) if "Lemak jenuh" in extracted else ""
354
- lemak_jenuh = st.text_input(
355
- "Lemak Jenuh (g)",
356
- value=lemak_jenuh_val,
357
- help="Masukkan kandungan lemak jenuh dalam gram"
358
- )
359
-
360
- submit_button = st.form_submit_button("🧮 Hitung Grade",
361
- type="primary",
362
- use_container_width=True)
363
-
364
- # --- STEP 4: Perhitungan Grade ---
365
- if submit_button:
366
- try:
367
- serving_size = parse_numeric_value(takaran_saji)
368
- sugar_value = parse_numeric_value(gula)
369
- fat_value = parse_numeric_value(lemak_jenuh)
370
-
371
- if serving_size <= 0:
372
- st.error("Takaran saji harus lebih besar dari 0!")
373
- st.stop()
374
-
375
- # Normalisasi ke per 100g/ml
376
- sugar_norm = (sugar_value / serving_size) * 100
377
- fat_norm = (fat_value / serving_size) * 100
378
-
379
- # Tampilkan hasil normalisasi
380
- st.header("📈 Hasil Analisis")
381
-
382
- col1, col2 = st.columns(2)
383
-
384
- with col1:
385
- st.subheader("📊 Tabel Normalisasi")
386
- data_tabel = {
387
- "Nutrisi": ["Gula", "Lemak Jenuh"],
388
- "Nilai Original": [f"{sugar_value} g", f"{fat_value} g"],
389
- "Per 100 g/ml": [f"{sugar_norm:.2f} g", f"{fat_norm:.2f} g"]
390
- }
391
- df_tabel = pd.DataFrame(data_tabel)
392
- st.dataframe(df_tabel, use_container_width=True)
393
-
394
- with col2:
395
- st.subheader("🎯 Standar Grading")
396
- st.write("**Gula (per 100g/ml):**")
397
- st.write("• Grade A: ≤ 1.0 g")
398
- st.write("• Grade B: ≤ 5.0 g")
399
- st.write("• Grade C: ≤ 10.0 g")
400
- st.write("• Grade D: > 10.0 g")
401
-
402
- st.write("**Lemak Jenuh (per 100g/ml):**")
403
- st.write("• Grade A: ≤ 0.7 g")
404
- st.write("• Grade B: ≤ 1.2 g")
405
- st.write("• Grade C: ≤ 2.8 g")
406
- st.write("• Grade D: > 2.8 g")
407
-
408
- # Hitung Grade
409
- def grade_from_value(value, thresholds):
410
- if value <= thresholds["A"]:
411
- return "Grade A"
412
- elif value <= thresholds["B"]:
413
- return "Grade B"
414
- elif value <= thresholds["C"]:
415
- return "Grade C"
416
- else:
417
- return "Grade D"
418
-
419
- thresholds_sugar = {"A": 1.0, "B": 5.0, "C": 10.0}
420
- thresholds_fat = {"A": 0.7, "B": 1.2, "C": 2.8}
421
-
422
- sugar_grade = grade_from_value(sugar_norm, thresholds_sugar)
423
- fat_grade = grade_from_value(fat_norm, thresholds_fat)
424
-
425
- # Tentukan grade akhir (yang terburuk)
426
- grade_scores = {"Grade A": 1, "Grade B": 2, "Grade C": 3, "Grade D": 4}
427
- worst_score = max(grade_scores[sugar_grade], grade_scores[fat_grade])
428
- inverse_scores = {v: k for k, v in grade_scores.items()}
429
- final_grade = inverse_scores[worst_score]
430
-
431
- # Tampilkan grade dengan warna
432
- st.subheader("🏆 Hasil Grading")
433
-
434
- def get_grade_color(grade_text):
435
- if grade_text == "Grade A":
436
- return "#2ecc71", "white"
437
- elif grade_text == "Grade B":
438
- return "#f1c40f", "black"
439
- elif grade_text == "Grade C":
440
- return "#e67e22", "white"
441
- else:
442
- return "#e74c3c", "white"
443
-
444
- col1, col2, col3 = st.columns(3)
445
-
446
- with col1:
447
- bg_color, text_color = get_grade_color(sugar_grade)
448
- st.markdown(f"""
449
- <div style="
450
- background-color: {bg_color};
451
- padding: 15px;
452
- border-radius: 10px;
453
- text-align: center;
454
- color: {text_color};
455
- font-weight: bold;
456
- margin: 5px;
457
- ">
458
- <h4 style="margin: 0; color: {text_color};">Gula</h4>
459
- <p style="margin: 5px 0; color: {text_color};">{sugar_norm:.2f} g</p>
460
- <h3 style="margin: 0; color: {text_color};">{sugar_grade}</h3>
461
- </div>
462
- """, unsafe_allow_html=True)
463
-
464
- with col2:
465
- bg_color, text_color = get_grade_color(fat_grade)
466
- st.markdown(f"""
467
- <div style="
468
- background-color: {bg_color};
469
- padding: 15px;
470
- border-radius: 10px;
471
- text-align: center;
472
- color: {text_color};
473
- font-weight: bold;
474
- margin: 5px;
475
- ">
476
- <h4 style="margin: 0; color: {text_color};">Lemak Jenuh</h4>
477
- <p style="margin: 5px 0; color: {text_color};">{fat_norm:.2f} g</p>
478
- <h3 style="margin: 0; color: {text_color};">{sugar_grade}</h3>
479
- </div>
480
- """, unsafe_allow_html=True)
481
-
482
- with col3:
483
- bg_color, text_color = get_grade_color(final_grade)
484
- st.markdown(f"""
485
- <div style="
486
- background-color: {bg_color};
487
- padding: 15px;
488
- border-radius: 10px;
489
- text-align: center;
490
- color: {text_color};
491
- font-weight: bold;
492
- margin: 5px;
493
- border: 3px solid #333;
494
- ">
495
- <h4 style="margin: 0; color: {text_color};">Grade Akhir</h4>
496
- <h2 style="margin: 10px 0; color: {text_color};">{final_grade}</h2>
497
- </div>
498
- """, unsafe_allow_html=True)
499
-
500
- # --- STEP 5: Saran Nutrisi dari AI ---
501
- st.header("🤖 Saran Nutrisi dari AI")
502
-
503
- with st.spinner("Qwen AI sedang menganalisis data nutrisi Anda..."):
504
- nutrition_advice = get_nutrition_advice(
505
- serving_size, sugar_norm, fat_norm,
506
- sugar_grade, fat_grade, final_grade
507
- )
508
-
509
- if nutrition_advice.startswith("Error"):
510
- st.error(f"Gagal mendapatkan saran nutrisi: {nutrition_advice}")
511
- st.info("Silakan coba lagi nanti atau hubungi tim pengembang.")
512
- else:
513
- st.success("Saran berhasil didapatkan!")
514
- st.markdown(f"""
515
- <div style="
516
- background-color: #f8f9fa;
517
- padding: 20px;
518
- border-radius: 10px;
519
- border-left: 5px solid #007BFF;
520
- margin: 10px 0;
521
- ">
522
- <h4>💡 Saran Nutrisi Personal</h4>
523
- <p style="font-size: 16px; line-height: 1.6;">{nutrition_advice}</p>
524
- </div>
525
- """, unsafe_allow_html=True)
526
-
527
- except Exception as e:
528
- st.error(f"Terjadi kesalahan dalam perhitungan: {e}")
529
- st.write("Silakan periksa kembali input data Anda.")
530
-
531
- except Exception as e:
532
- st.error(f"Terjadi kesalahan dalam proses OCR: {e}")
533
- st.write("Silakan coba dengan gambar yang berbeda atau hubungi tim pengembang.")
534
-
535
- # Cleanup file sementara
536
  try:
537
- if os.path.exists(temp_img_path):
538
- os.remove(temp_img_path)
539
  except:
540
- pass
541
-
542
- except Exception as e:
543
- st.error(f"Terjadi kesalahan dalam memproses gambar: {e}")
544
 
545
- # --- Footer: Tim Pengembang ---
546
- st.markdown("---")
547
- st.markdown("""
548
- <div style="border: 2px solid #007BFF; padding: 20px; border-radius: 10px; margin-top: 30px; background-color: #f8f9fa;">
549
- <h3 style="color: #007BFF; text-align: center;">👥 Tim Pengembang</h3>
550
- <div style="display: flex; justify-content: space-around; flex-wrap: wrap;">
551
- <div style="text-align: center; margin: 10px;">
552
- <h4>Nicholas Dominic</h4>
553
- <p><strong>Mentor</strong></p>
554
- <a href="https://www.linkedin.com/in/nicholas-dominic" target="_blank">
555
- <button style="background-color: #0077B5; color: white; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer;">
556
- LinkedIn
557
- </button>
558
- </a>
559
- </div>
560
- <div style="text-align: center; margin: 10px;">
561
- <h4>Tata Aditya Pamungkas</h4>
562
- <p><strong>Machine Learning</strong></p>
563
- <a href="https://www.linkedin.com/in/tata-aditya-pamungkas" target="_blank">
564
- <button style="background-color: #0077B5; color: white; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer;">
565
- LinkedIn
566
- </button>
567
- </a>
568
- </div>
569
- <div style="text-align: center; margin: 10px;">
570
- <h4>Raihan Hafiz</h4>
571
- <p><strong>Web Development</strong></p>
572
- <a href="https://www.linkedin.com/in/m-raihan-hafiz-91a368186" target="_blank">
573
- <button style="background-color: #0077B5; color: white; border: none; padding: 8px 16px; border-radius: 5px; cursor: pointer;">
574
- LinkedIn
575
- </button>
576
- </a>
577
- </div>
578
- </div>
579
- </div>
580
- """, unsafe_allow_html=True)
581
 
582
- with st.expander("🚀 Roadmap Pengembangan"):
583
- st.markdown("""
584
- **Inovasi yang sedang dikembangkan:**
585
-
586
- 1. **Infrastruktur Premium**
587
- - Migrasi ke server berbayar untuk menampung lebih banyak pengguna
588
- - Optimasi kecepatan dan stabilitas aplikasi
589
-
590
- 2. **Fitur Recall Asupan Harian**
591
- - Tracking asupan makanan real food sehari-hari
592
- - Konsultasi dengan ahli gizi profesional ([Firzah Marhamah, RD](https://www.linkedin.com/in/firza-marhamah))
593
- - Database makanan Indonesia yang komprehensif
594
-
595
- 3. **Kalkulator Kalori Personal**
596
- - Perhitungan kebutuhan kalori berdasarkan profil pengguna
597
- - Rekomendasi menu harian yang seimbang
598
- - Tracking progress dan pencapaian target nutrisi
599
-
600
- 4. **Integrasi AI yang Lebih Canggih**
601
- - Analisis pola makan pengguna
602
- - Prediksi risiko kesehatan berdasarkan riwayat konsumsi
603
- - Chatbot nutrisi untuk konsultasi real-time
604
-
605
- 5. **Fitur Komunitas**
606
- - Sharing resep makanan sehat
607
- - Challenge dan kompetisi hidup sehat
608
- - Forum diskusi dengan ahli gizi
609
- """)
610
 
611
- # Sidebar informasi tambahan
612
- with st.sidebar:
613
- st.header("ℹ️ Informasi Aplikasi")
614
-
615
- st.subheader("🎯 Tentang NutriGrade")
616
- st.write("""
617
- Sistem grading berdasarkan standar Health Promotion Board Singapura
618
- yang mengkategorikan makanan berdasarkan kandungan gula dan lemak jenuh.
619
- """)
620
-
621
- st.subheader("📊 Standar Grading")
622
- st.write("**Gula (per 100g/ml):**")
623
- st.write("🟢 A: ≤ 1.0g | 🟡 B: ≤ 5.0g")
624
- st.write("🟠 C: ≤ 10.0g | 🔴 D: > 10.0g")
625
-
626
- st.write("**Lemak Jenuh (per 100g/ml):**")
627
- st.write("🟢 A: ≤ 0.7g | 🟡 B: ≤ 1.2g")
628
- st.write("🟠 C: ≤ 2.8g | 🔴 D: > 2.8g")
629
-
630
- st.subheader("🔧 Status Sistem")
631
- if st.session_state.get('ocr_model') is not None:
632
- st.success("✅ OCR Model: Ready")
633
- else:
634
- st.error("❌ OCR Model: Not Ready")
635
-
636
- st.success("✅ API: Connected")
637
- st.info("🌐 Hosting: Hugging Face Spaces")
638
-
639
- st.subheader("📱 Tips Penggunaan")
640
- st.write("""
641
- • Pastikan gambar jelas dan tidak buram
642
- • Tabel gizi harus terlihat dengan baik
643
- • Hindari gambar dengan pencahayaan buruk
644
- • Untuk hasil terbaik, gunakan gambar portrait mode
645
- """)
646
-
647
- st.subheader("🆘 Bantuan")
648
- st.write("Jika mengalami masalah:")
649
- st.write("1. Refresh halaman")
650
- st.write("2. Coba dengan gambar yang berbeda")
651
- st.write("3. Hubungi tim pengembang")
652
 
653
- # CSS untuk styling tambahan
654
- st.markdown("""
655
- <style>
656
- .main > div {
657
- padding-top: 2rem;
658
- }
659
-
660
- .stButton > button {
661
- width: 100%;
662
- border-radius: 10px;
663
- height: 3em;
664
- font-weight: bold;
665
- }
666
-
667
- .stButton > button:hover {
668
- transform: translateY(-2px);
669
- box-shadow: 0 4px 8px rgba(0,0,0,0.2);
670
- transition: all 0.3s ease;
671
- }
672
-
673
- .uploadedFile {
674
- border: 2px dashed #007BFF;
675
- border-radius: 10px;
676
- padding: 20px;
677
- text-align: center;
678
- margin: 10px 0;
679
- }
680
-
681
- .stDataFrame {
682
- border-radius: 10px;
683
- overflow: hidden;
684
- }
685
-
686
- .stExpander {
687
- border-radius: 10px;
688
- border: 1px solid #e0e0e0;
689
- }
690
-
691
- .stSuccess, .stError, .stWarning, .stInfo {
692
- border-radius: 10px;
693
- padding: 15px;
694
- margin: 10px 0;
695
- }
696
-
697
- .grade-card {
698
- transition: transform 0.3s ease;
699
- }
700
-
701
- .grade-card:hover {
702
- transform: scale(1.05);
703
- }
704
-
705
- /* Responsive design */
706
- @media (max-width: 768px) {
707
- .stColumns {
708
- flex-direction: column;
709
- }
710
-
711
- .stButton > button {
712
- height: 2.5em;
713
- font-size: 14px;
714
- }
715
- }
716
-
717
- /* Loading animation */
718
- .stSpinner {
719
- border-radius: 50%;
720
- animation: spin 1s linear infinite;
721
- }
722
-
723
- @keyframes spin {
724
- 0% { transform: rotate(0deg); }
725
- 100% { transform: rotate(360deg); }
726
- }
727
-
728
- /* Custom scrollbar */
729
- ::-webkit-scrollbar {
730
- width: 8px;
731
- }
732
-
733
- ::-webkit-scrollbar-track {
734
- background: #f1f1f1;
735
- border-radius: 10px;
736
- }
737
-
738
- ::-webkit-scrollbar-thumb {
739
- background: #007BFF;
740
- border-radius: 10px;
741
- }
742
-
743
- ::-webkit-scrollbar-thumb:hover {
744
- background: #0056b3;
745
- }
746
- </style>
747
- """, unsafe_allow_html=True)
748
 
749
- # JavaScript untuk interaksi tambahan
750
- st.markdown("""
751
- <script>
752
- // Auto-scroll ke hasil setelah analisis
753
- function scrollToResults() {
754
- setTimeout(function() {
755
- const results = document.querySelector('[data-testid="stHeader"]');
756
- if (results) {
757
- results.scrollIntoView({ behavior: 'smooth' });
758
- }
759
- }, 1000);
760
- }
761
 
762
- // Notifikasi browser jika didukung
763
- function showNotification(title, message) {
764
- if ("Notification" in window) {
765
- if (Notification.permission === "granted") {
766
- new Notification(title, {
767
- body: message,
768
- icon: "🥗"
769
- });
770
- } else if (Notification.permission !== "denied") {
771
- Notification.requestPermission().then(function (permission) {
772
- if (permission === "granted") {
773
- new Notification(title, {
774
- body: message,
775
- icon: "🥗"
776
- });
777
- }
778
- });
779
- }
780
- }
781
- }
782
 
783
- // Hapus file sementara saat halaman ditutup
784
- window.addEventListener('beforeunload', function() {
785
- // Cleanup logic bisa ditambahkan di sini
786
- });
787
- </script>
788
- """, unsafe_allow_html=True)
 
 
 
 
 
 
789
 
790
- # Footer dengan informasi kontak dan versi
 
 
 
 
 
791
  st.markdown("---")
 
 
792
  st.markdown("""
793
- <div style="text-align: center; padding: 20px; background-color: #f8f9fa; border-radius: 10px; margin-top: 20px;">
794
- <p style="color: #666; margin: 5px 0;">
795
- <strong>Nutri-Grade Detection App v2.0</strong><br>
796
- Dikembangkan dengan ❤️ untuk kesehatan masyarakat Indonesia<br>
797
- <small>Powered by PaddleOCR, OpenRouter API, dan Streamlit</small>
798
- </p>
799
- <p style="color: #888; font-size: 12px; margin-top: 15px;">
800
- © 2024 Tim Nutri-Grade | Semua hak dilindungi undang-undang<br>
801
- <a href="https://huggingface.co/spaces/your-username/nutri-grade" target="_blank" style="color: #007BFF; text-decoration: none;">
802
- 🤗 Hugging Face Repository
803
- </a> |
804
- <a href="mailto:[email protected]" style="color: #007BFF; text-decoration: none;">
805
- 📧 Kontak
806
- </a> |
807
- <a href="#" style="color: #007BFF; text-decoration: none;">
808
- 📋 Terms of Service
809
- </a>
810
- </p>
811
  </div>
812
- """, unsafe_allow_html=True)
 
 
 
 
 
 
 
 
1
+ # ==============================================================================
2
+ # 1. IMPORT LIBRARY
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 Calculator",
20
+ page_icon="🍏",
21
+ layout="centered",
22
+ initial_sidebar_state="auto"
23
  )
24
 
25
+ # --- Konfigurasi Kunci API dan Model ---
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
+ openai.api_key = st.secrets["OPENAI_API_KEY"]
32
+ except (KeyError, FileNotFoundError):
33
+ st.error("Kunci API OpenRouter tidak ditemukan. Harap atur di st.secrets.")
34
+ st.stop()
35
 
36
+ openai.api_base = "https://openrouter.ai/api/v1"
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
+ # 3. FUNGSI-FUNGSI UTAMA
48
+ # ==============================================================================
 
 
 
 
 
 
 
49
 
 
50
  @st.cache_resource
51
+ def load_ocr_model():
52
+ """
53
+ Memuat model PaddleOCR dan menyimpannya di cache.
54
+ Menggunakan CPU untuk kompatibilitas yang lebih baik di Hugging Face Spaces.
55
+ """
56
+ print("Memuat model PaddleOCR...")
57
+ # PENTING: use_gpu=False untuk stabilitas di environment tanpa GPU yang terkonfigurasi.
58
+ # Ini adalah perbaikan utama untuk error 'Failed to parse program_desc'.
59
+ return PaddleOCR(use_gpu=False, lang='id', cls=True)
60
+
61
+ def parse_numeric_value(text: str) -> float:
62
+ """
63
+ Membersihkan string dan mengubahnya menjadi float.
64
+ Contoh: "15g" -> 15.0 atau "Sekitar 12.5" -> 12.5
65
+ """
66
+ if not isinstance(text, str):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 perform_ocr(image_path: str, ocr_model) -> list:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  """
77
+ Melakukan OCR pada gambar dan mengembalikan hasil dalam format yang terstruktur.
78
+ """
79
+ if not image_path:
80
+ return []
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
+ # Pass 1: Mencari key yang diikuti oleh titik dua (contoh: "Gula: 10g")
110
+ for item in ocr_data:
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.capitalize() not in extracted:
130
+ for variant in variants:
131
+ if variant in txt_lower:
132
+ key_center_y, key_center_x, key_height = item["center_y"], item["center_x"], item["height"]
133
+ best_candidate = None
134
+ min_horizontal_dist = float('inf')
135
+
136
+ for other in ocr_data:
137
+ # Cari kandidat di sebelah kanan dan sejajar secara vertikal
138
+ is_aligned_y = abs(other["center_y"] - key_center_y) < key_height * 0.75
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 calculate_final_grade(sugar_norm: float, fat_norm: float) -> (str, str, str):
156
+ """
157
+ Menghitung grade untuk gula, lemak jenuh, dan grade akhir.
158
+ """
159
+ thresholds = {
160
+ "sugar": {"A": 1.0, "B": 5.0, "C": 10.0},
161
+ "fat": {"A": 0.7, "B": 1.2, "C": 2.8}
162
  }
163
+ grade_scores = {"A": 1, "B": 2, "C": 3, "D": 4}
164
 
165
+ def get_grade(value, nutrient_type):
166
+ if value <= thresholds[nutrient_type]["A"]: return "A"
167
+ if value <= thresholds[nutrient_type]["B"]: return "B"
168
+ if value <= thresholds[nutrient_type]["C"]: return "C"
169
+ return "D"
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
+ def generate_nutrition_advice(data: dict) -> str:
180
+ """
181
+ Membuat prompt dan memanggil API LLM untuk mendapatkan saran nutrisi.
182
+ """
183
+ nutrition_prompt = f"""
184
+ Anda adalah seorang ahli gizi dari Indonesia yang ramah, komunikatif, dan berpengalaman.
185
+ Berikut adalah data nutrisi sebuah produk makanan:
186
+ - Takaran Saji: {data['serving_size']:.2f} g/ml
187
+ - Kandungan Gula (setelah normalisasi per 100g): {data['sugar_norm']:.2f} g
188
+ - Kandungan Lemak Jenuh (setelah normalisasi per 100g): {data['fat_norm']:.2f} g
189
+ - Grade Gula: {data['sugar_grade']}
190
+ - Grade Lemak Jenuh: {data['fat_grade']}
191
+ - Grade Akhir Produk: {data['final_grade']}
192
+
193
+ Tugas Anda:
194
+ Berikan saran nutrisi yang informatif dalam satu paragraf pendek (sekitar 50-100 kata).
195
+ Gunakan bahasa yang bersahabat dan mudah dimengerti. Jelaskan secara ringkas arti dari data nutrisi di atas,
196
+ dampak kesehatan terkait, dan berikan tips praktis untuk menjaga pola makan seimbang.
197
+ """
198
+ st.write("Tunggu sebentar, Qwen si AI nutritionist sedang memproses penjelasannya... 🤖")
199
  try:
200
+ completion = openai.ChatCompletion.create(
201
+ model=AI_MODEL_NAME,
202
+ messages=[{"role": "user", "content": nutrition_prompt}]
 
 
203
  )
204
+ return completion.choices[0].message.content
 
 
 
 
 
 
 
 
 
 
205
  except Exception as e:
206
+ return f"Gagal mendapatkan saran dari Qwen: {e}"
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
+ # 4. TAMPILAN ANTARMUKA (USER INTERFACE)
238
+ # ==============================================================================
239
+
240
+ # --- Judul dan Deskripsi ---
241
+ st.title("🍏 Nutri-Grade Label & Grade Calculator")
242
+ st.caption("Aplikasi prototipe untuk menganalisis dan memberi grade pada label nutrisi produk, terinspirasi oleh Nutri-Grade Singapura. Refresh halaman jika terjadi masalah.")
243
+
244
+ # --- Petunjuk Penggunaan dan Info ---
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
+ - Aplikasi ini masih dalam tahap **pengembangan (prototipe)**.
256
+ - Hasil ekstraksi otomatis mungkin tidak 100% akurat. **Selalu verifikasi dengan label fisik**.
257
+ - Dijalankan pada server gratis, mohon maaf jika terkadang lambat atau mengalami kendala.
258
+ - Kode sumber tersedia di [Hugging Face](https://huggingface.co/spaces/tataaditya/nutri-grade). Kontribusi dan feedback sangat kami hargai.
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 = load_ocr_model()
264
 
265
  # --- STEP 1: Upload Gambar ---
 
266
  uploaded_file = st.file_uploader(
267
+ "Upload gambar tabel gizi di sini (JPG/PNG)",
268
+ type=["jpg", "jpeg", "png"]
 
269
  )
270
 
271
  if uploaded_file is not None:
272
+ # Menggunakan session state untuk menyimpan hasil agar tidak perlu diulang
273
+ if 'last_uploaded_file' not in st.session_state or st.session_state.last_uploaded_file != uploaded_file.name:
274
+ st.session_state.last_uploaded_file = uploaded_file.name
275
+ st.session_state.ocr_data = None
276
+ st.session_state.extracted_data = {}
277
+
278
+ # Konversi dan tampilkan gambar
279
+ image_bytes = np.asarray(bytearray(uploaded_file.read()), dtype=np.uint8)
280
+ img = cv2.imdecode(image_bytes, 1)
281
+ img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
282
+ st.image(img_rgb, caption="Gambar yang diunggah", use_column_width=True)
283
+
284
+ # Simpan gambar sementara untuk diproses OCR
285
+ img_path = "uploaded_image.jpg"
286
+ cv2.imwrite(img_path, img)
287
+
288
+ # --- STEP 2: Proses OCR (hanya jika belum ada datanya) ---
289
+ if st.session_state.ocr_data is None:
290
+ with st.spinner("Membaca teks dari gambar... Ini mungkin memakan waktu beberapa detik."):
291
+ start_time = time.time()
292
+ st.session_state.ocr_data = perform_ocr(img_path, ocr_model)
293
+ ocr_time = time.time() - start_time
294
 
295
+ if not st.session_state.ocr_data:
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
+ corrected_data = {}
322
+ # Ambil nilai dari session state sebagai default
323
+ extracted_data = st.session_state.extracted_data
324
+
325
+ for key in TARGET_KEYS.keys():
326
+ key_cap = key.capitalize()
327
+ # Ambil nilai yang sudah diekstrak, jika tidak ada, biarkan kosong
328
+ default_val = extracted_data.get(key_cap, "")
329
+ corrected_data[key_cap] = st.text_input(
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
+ try:
339
+ # Ambil nilai dari form yang sudah dikoreksi
340
+ serving_size = parse_numeric_value(corrected_data.get("Takaran saji", "100"))
341
+ sugar_value = parse_numeric_value(corrected_data.get("Gula", "0"))
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
+ # 5. FOOTER
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
+ """)