YourAIEngineer commited on
Commit
ac27d7e
·
verified ·
1 Parent(s): afdead2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +184 -343
app.py CHANGED
@@ -1,408 +1,249 @@
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
- """)
 
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.")