YourAIEngineer commited on
Commit
178cc73
Β·
verified Β·
1 Parent(s): 0bbf223

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +264 -357
app.py CHANGED
@@ -1,383 +1,290 @@
1
- # app.py
2
- import streamlit as st
 
 
 
3
  import cv2
4
  import numpy as np
5
- import re
 
 
 
6
  from PIL import Image
7
- import io
8
- import math
9
-
10
- # Try import PaddleOCR with graceful failure message inside app
11
- try:
12
- from paddleocr import PaddleOCR
13
- except Exception:
14
- PaddleOCR = None
15
-
16
- # -----------------------
17
- # CONFIG
18
- # -----------------------
19
- st.set_page_config(
20
- page_title="Nutri-Grade Label Detection",
21
- page_icon="πŸ₯—",
22
- layout="wide",
23
- initial_sidebar_state="expanded"
24
- )
25
-
26
- # Session keys
27
- KEYS = {
28
- "ocr_done": "ocr_done_v2",
29
- "ocr_text": "ocr_text_v2",
30
- "data": "data_v2",
31
- "calc": "calc_v2",
32
- "calculated": "calculated_v2",
33
- }
34
-
35
- # -----------------------
36
- # UTILITIES
37
- # -----------------------
38
- def safe_parse_number(s: str) -> float:
39
- if s is None:
40
- return 0.0
41
- s = str(s).strip()
42
- if s == "":
43
  return 0.0
44
- is_negative = False
45
- if s.startswith("(") and s.endswith(")"):
46
- is_negative = True
47
- s = s[1:-1].strip()
48
- s = re.sub(r"[^\d\.,\-\/]", "", s)
49
- if s == "" or s in ["-", ".", ","]:
50
  return 0.0
51
- if "/" in s and not any(c.isalpha() for c in s):
52
- try:
53
- a, b = s.split("/", 1)
54
- val = float(a) / float(b)
55
- return -val if is_negative else val
56
- except:
57
- pass
58
- if "." in s and "," in s:
59
- if s.rfind(".") > s.rfind(","):
60
- s = s.replace(",", "")
61
- else:
62
- s = s.replace(".", "").replace(",", ".")
63
- else:
64
- if "," in s and "." not in s:
65
- parts = s.split(",")
66
- if len(parts[-1]) in (1,2,3):
67
- s = s.replace(",", ".")
68
- else:
69
- s = s.replace(",", "")
70
  try:
71
- val = float(s)
72
- return -val if is_negative else val
73
- except:
74
- m = re.search(r"-?\d+([.,]\d+)?", s)
75
- if m:
76
- return float(m.group(0).replace(",", "."))
77
- return 0.0
78
-
79
- def preprocess_for_ocr(img: np.ndarray, max_dim=1600):
80
- if len(img.shape) == 2:
81
- img = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
82
- h, w = img.shape[:2]
83
- if max(h, w) > max_dim:
84
- scale = max_dim / max(h, w)
85
- img = cv2.resize(img, (int(w*scale), int(h*scale)), interpolation=cv2.INTER_AREA)
86
  try:
87
- img = cv2.fastNlMeansDenoisingColored(img, None, 10, 10, 7, 21)
 
 
 
 
 
 
88
  except Exception:
89
- pass
90
- img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
91
- return img_rgb
92
-
93
- def try_ocr_variants(ocr, img_rgb):
94
- variants = []
95
- rotations = [0, 90, 180, 270]
96
- scales = [1.0, 1.5, 0.8]
97
- for rot in rotations:
98
- if rot != 0:
99
- M = cv2.getRotationMatrix2D((img_rgb.shape[1]/2, img_rgb.shape[0]/2), rot, 1.0)
100
- imgr = cv2.warpAffine(img_rgb, M, (img_rgb.shape[1], img_rgb.shape[0]), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
101
- else:
102
- imgr = img_rgb
103
- for s in scales:
104
- if s != 1.0:
105
- imgrs = cv2.resize(imgr, (0,0), fx=s, fy=s, interpolation=cv2.INTER_CUBIC)
106
- else:
107
- imgrs = imgr
108
- try:
109
- ocr_res = ocr.ocr(imgrs, cls=True)
110
- except Exception:
111
- try:
112
- ocr_res = ocr.ocr(imgrs)
113
- except Exception:
114
- ocr_res = None
115
- text_lines = []
116
- conf_sum = 0.0
117
- num_tokens = 0
118
- if ocr_res:
119
- flat = ocr_res
120
- for item in flat:
121
- try:
122
- entry = item[1] if isinstance(item, (list, tuple)) and len(item) > 1 else item
123
- if isinstance(entry, (list, tuple)):
124
- txt = entry[0]
125
- prob = float(entry[1]) if len(entry) > 1 else 0.0
126
- elif isinstance(entry, dict):
127
- txt = entry.get("text", "")
128
- prob = float(entry.get("confidence", 0.0))
129
- else:
130
- txt = str(entry)
131
- prob = 0.0
132
- except Exception:
133
- txt = str(item)
134
- prob = 0.0
135
- text_lines.append(txt)
136
- try:
137
- conf_sum += float(prob)
138
- except:
139
- pass
140
- num_tokens += len(re.findall(r"[\d]+", txt))
141
- joined = " ".join(text_lines)
142
- score = num_tokens + conf_sum
143
- variants.append({"rot": rot, "scale": s, "score": score, "text": joined, "raw": ocr_res})
144
- variants = sorted(variants, key=lambda x: x["score"], reverse=True)
145
- return variants[0] if variants else {"text": ""}
146
-
147
- def extract_nutrition_info(full_text: str) -> dict:
148
- txt = (full_text or "").lower()
149
- serving = None
150
- serving_unit = None
151
- m = re.search(r"(takaran saj(?:i|a)|serving size|serving)[^\d]{0,10}([-\d\.,\/ ]{1,20})", txt)
152
- if m:
153
- serving = m.group(2).strip()
154
- else:
155
- m2 = re.search(r"(\d{1,3}(?:[.,]\d+)?)(?:\s*)(ml|g)\b", txt)
156
- if m2:
157
- serving = m2.group(1)
158
- serving_unit = m2.group(2)
159
- sugar = None
160
- sugar_unit = None
161
- sugar_patterns = [
162
- r"(gula|sugar)[^\d\-\,\.\d]{0,6}([-\d\.,\/ ]{1,20})",
163
- r"sugars?[^\d]{0,6}([-\d\.,\/ ]{1,20})",
164
- ]
165
- for p in sugar_patterns:
166
- m = re.search(p, txt)
167
- if m:
168
- sugar = m.group(2).strip()
169
- break
170
- fat = None
171
- fat_unit = None
172
- fat_patterns = [
173
- r"(lemak jenuh|saturated fat|sat fat)[^\d\-\,\.\d]{0,6}([-\d\.,\/ ]{1,20})",
174
- r"(lemak total|total fat)[^\d\-\,\.\d]{0,6}([-\d\.,\/ ]{1,20})"
175
- ]
176
- for p in fat_patterns:
177
- m = re.search(p, txt)
178
- if m:
179
- fat = m.group(2).strip()
180
- break
181
- per100 = False
182
- if re.search(r"(per 100|per 100g|per 100 ml|/100g|/100 ml|/100ml|/100g)", txt):
183
- per100 = True
184
- if not sugar or sugar == "":
185
- nums = re.findall(r"[-+]?\d{1,3}(?:[.,]\d{3})*(?:[.,]\d+)?", txt)
186
- if nums:
187
- for n in nums:
188
- v = safe_parse_number(n)
189
- if 0 <= v <= 100:
190
- sugar = n
191
- break
192
- return {
193
- "serving_raw": serving or "",
194
- "serving_unit": serving_unit or "",
195
- "sugar_raw": sugar or "",
196
- "sugar_unit": sugar_unit or "",
197
- "fat_raw": fat or "",
198
- "fat_unit": fat_unit or "",
199
- "values_are_per100": per100
200
- }
201
 
202
- def normalize_to_per_100(value_raw: str, unit_raw: str, serving_size: float, values_are_per100: bool) -> float:
203
- if value_raw is None or str(value_raw).strip() == "":
204
- return 0.0
205
- s = str(value_raw).lower()
206
- s = re.sub(r"(g|mg|ml)\b", "", s)
207
- val = safe_parse_number(s)
208
- if values_are_per100:
209
- return val
210
- if serving_size and serving_size > 0:
211
- try:
212
- per100 = (val / serving_size) * 100.0
213
- return per100
214
- except Exception:
215
- return val
216
- return val
217
-
218
- def get_grade_from_value(value: float, thresholds: dict) -> str:
219
- try:
220
- v = float(value)
221
- except:
222
- v = 9999.0
223
- if v <= thresholds.get("A", 0):
224
- return "Grade A"
225
- if v <= thresholds.get("B", float("inf")):
226
- return "Grade B"
227
- if v <= thresholds.get("C", float("inf")):
228
- return "Grade C"
229
- return "Grade D"
230
-
231
- def get_grade_color(grade: str):
232
  colors = {
233
  "Grade A": ("#2ecc71", "white"),
234
  "Grade B": ("#f1c40f", "black"),
235
  "Grade C": ("#e67e22", "white"),
236
- "Grade D": ("#e74c3c", "white")
237
  }
238
  return colors.get(grade, ("#bdc3c7", "black"))
239
 
240
- def nutrition_advice_rule_based(final_grade: str, sugar_per100: float, fat_per100: float):
241
- adv = []
242
- if final_grade == "Grade A":
243
- adv.append("Produk tergolong baik. Konsumsi sesuai porsi.")
244
- elif final_grade == "Grade B":
245
- adv.append("Cukup baik, perhatikan frekuensi konsumsi.")
246
- elif final_grade == "Grade C":
247
- adv.append("Perhatikan kandungan gula/lemak. Batasi konsumsi harian.")
248
- else:
249
- adv.append("Kandungan gula/lemak tinggi. Kurangi frekuensi, pilih alternatif lebih sehat.")
250
- if sugar_per100 > 10:
251
- adv.append("Gula per 100g cukup tinggi β€” hindari dikonsumsi berlebihan.")
252
- elif sugar_per100 > 5:
253
- adv.append("Perhatikan gula; hindari menambah gula lagi.")
254
- if fat_per100 > 5:
255
- adv.append("Lemak jenuh tinggi β€” perhatikan asupan harian dan coba variasi rendah lemak.")
256
- return " ".join(adv)
257
-
258
- # -----------------------
259
- # INITIALIZE OCR (cached)
260
- # -----------------------
261
  @st.cache_resource
262
- def init_ocr_model():
263
- if PaddleOCR is None:
264
- return None
265
  try:
266
- return PaddleOCR(lang="en", use_angle_cls=True, use_gpu=False)
267
- except Exception:
 
 
268
  return None
269
 
270
- ocr_model = init_ocr_model()
271
-
272
- # -----------------------
273
- # UI
274
- # -----------------------
275
- st.title("πŸ₯— Nutri-Grade Detection & Grade Calculator (No AI Chatbot)")
276
- st.caption("Analisis gizi berdasarkan data pada label. Koreksi manual selalu disarankan.")
277
-
278
- with st.expander("πŸ“‹ Petunjuk"):
279
- st.markdown(
280
- """
281
- 1. Upload gambar tabel gizi (jpg/png).
282
- 2. Klik **Analisis OCR** (butuh PaddleOCR terinstall).
283
- 3. Koreksi hasil jika perlu.
284
- 4. Klik **Hitung Grade**.
285
- """
286
- )
287
 
288
- if PaddleOCR is None:
289
- st.error("PaddleOCR tidak ditemukan. Pastikan package `paddleocr` (dan `paddlepaddle`) telah terinstall di environment Anda.")
290
- st.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
291
 
292
- if ocr_model is None:
293
- st.error("Gagal inisialisasi model OCR. Jika di Spaces atau Docker, pastikan dependency paddle telah terpasang dengan benar.")
294
- st.stop()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
 
296
- st.header("1. Upload Gambar")
297
- uploaded = st.file_uploader("Pilih gambar (jpg, jpeg, png)", type=["jpg","jpeg","png"])
298
 
 
 
 
299
  def reset_state():
300
- for v in KEYS.values():
301
- if v in st.session_state:
302
- del st.session_state[v]
303
-
304
- if uploaded:
305
- file_bytes = np.asarray(bytearray(uploaded.read()), dtype=np.uint8)
306
- img = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)
307
- st.image(cv2.cvtColor(img, cv2.COLOR_BGR2RGB), caption="Preview", use_column_width=False, width=350)
308
-
309
- if st.button("Analisis OCR"):
310
- with st.spinner("Memproses gambar dan menjalankan OCR..."):
311
- try:
312
- img_rgb = preprocess_for_ocr(img)
313
- best = try_ocr_variants(ocr_model, img_rgb)
314
- extracted = extract_nutrition_info(best.get("text", ""))
315
- st.session_state[KEYS["ocr_done"]] = True
316
- st.session_state[KEYS["ocr_text"]] = best.get("text", "")
317
- st.session_state[KEYS["data"]] = extracted
318
- st.success("OCR selesai. Mohon periksa dan koreksi nilai-nilai yang terdeteksi.")
319
- except Exception as e:
320
- st.error(f"Gagal menjalankan OCR: {e}")
321
-
322
- if st.session_state.get(KEYS["ocr_done"], False):
323
- st.header("2. Koreksi & Hitung Grade")
324
- d = st.session_state.get(KEYS["data"], {})
325
- with st.form("form_calc"):
326
- serving_raw = st.text_input("Takaran Saji (mis. '100 g' atau '250 ml')", value=d.get("serving_raw", "100"))
327
- sugar_raw = st.text_input("Nilai Gula (teks mentah dari OCR)", value=d.get("sugar_raw", ""))
328
- fat_raw = st.text_input("Nilai Lemak Jenuh (teks mentah dari OCR)", value=d.get("fat_raw", ""))
329
- values_are_per100 = st.checkbox("Nilai yang terdeteksi sudah per 100g/ml", value=d.get("values_are_per100", False))
330
- submitted = st.form_submit_button("Hitung Grade")
331
- if submitted:
332
- m = re.search(r"(\d{1,4}(?:[.,]\d+)?)\s*(ml|g)?", serving_raw.lower())
333
- serving_size = 0.0
334
- serving_unit = ""
335
- if m:
336
- serving_size = safe_parse_number(m.group(1))
337
- serving_unit = (m.group(2) or "").lower()
338
- else:
339
- serving_size = safe_parse_number(serving_raw)
340
- sugar_per100 = normalize_to_per_100(sugar_raw, "", serving_size, values_are_per100)
341
- fat_per100 = normalize_to_per_100(fat_raw, "", serving_size, values_are_per100)
342
- st.session_state[KEYS["calc"]] = {"serving_size": serving_size, "serving_unit": serving_unit, "sugar_per100": sugar_per100, "fat_per100": fat_per100}
343
- st.session_state[KEYS["calculated"]] = True
344
-
345
- if st.session_state.get(KEYS["calculated"], False):
346
- st.header("3. Hasil Grading & Saran")
347
- c = st.session_state[KEYS["calc"]]
348
- sugar_thresholds = {"A": 5.0, "B": 12.5, "C": 22.5}
349
- fat_thresholds = {"A": 1.0, "B": 3.0, "C": 5.0}
350
- gs = get_grade_from_value(c["sugar_per100"], sugar_thresholds)
351
- gf = get_grade_from_value(c["fat_per100"], fat_thresholds)
352
- order = ["Grade A", "Grade B", "Grade C", "Grade D"]
353
- final = max([gs, gf], key=lambda x: order.index(x))
354
-
355
- cols = st.columns(3)
356
- def show(col, title, value, unit, grade):
357
- bg, tc = get_grade_color(grade)
358
- col.markdown(
359
- f"""
360
- <div style="padding:12px;border-radius:8px;background:{bg};color:{tc};text-align:center;">
361
- <strong style="font-size:18px;">{title}</strong>
362
- <div style="font-size:24px;margin-top:6px;">{value:.2f} {unit}</div>
363
- <div style="font-size:20px;margin-top:8px;"><strong>{grade}</strong></div>
364
- </div>
365
- """,
366
- unsafe_allow_html=True
367
  )
368
- show(cols[0], "Gula (per 100g/ml)", c["sugar_per100"], "g/100", gs)
369
- show(cols[1], "Lemak Jenuh (per 100g/ml)", c["fat_per100"], "g/100", gf)
370
- show(cols[2], "Grade Akhir", 0.0, "", final)
371
-
372
- st.markdown("---")
373
- st.subheader("Saran Nutrisi (rule-based)")
374
- advice = nutrition_advice_rule_based(final, c["sugar_per100"], c["fat_per100"])
375
- st.info(advice)
376
-
377
- st.markdown("**Catatan:** Hasil tergantung akurasi OCR dan input. Jika nilai terlihat aneh, koreksi secara manual pada langkah 2 lalu hitung ulang.")
378
-
379
- st.markdown("---")
380
- st.write("Nutri-Grade App (improved). Pastikan memeriksa manual hasil OCR sebelum mengambil keputusan.")
381
- if st.button("Reset Aplikasi"):
382
- reset_state()
383
- st.experimental_rerun()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import time
4
+ from typing import Dict, Optional, Tuple
5
+
6
  import cv2
7
  import numpy as np
8
+ import pandas as pd
9
+ import requests
10
+ import streamlit as st
11
+ from paddleocr import PaddleOCR
12
  from PIL import Image
13
+
14
+ # ---------------------------
15
+ # Konfigurasi / Constants
16
+ # ---------------------------
17
+ APP_TITLE = "πŸ₯— Nutri-Grade Label Detection"
18
+ APP_VERSION = "v2.1 (refactor)"
19
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
20
+ GRADE_ORDER = ["Grade A", "Grade B", "Grade C", "Grade D"]
21
+
22
+ # ---------------------------
23
+ # Util / Helpers
24
+ # ---------------------------
25
+ def get_openrouter_api_key() -> Optional[str]:
26
+ """
27
+ Ambil API key dari environment variable atau dari Streamlit secrets.
28
+ Jangan meletakkan API key langsung di kode.
29
+ """
30
+ return os.environ.get("OPENROUTER_API_KEY") or st.secrets.get("OPENROUTER_API_KEY", None)
31
+
32
+
33
+ def safe_float_from_text(txt: str) -> float:
34
+ """
35
+ Ambil angka dari teks (mengatasi koma desimal) dan konversi ke float.
36
+ Jika gagal, kembalikan 0.0.
37
+ """
38
+ if txt is None:
 
 
 
 
 
 
 
 
 
 
39
  return 0.0
40
+ s = str(txt).strip()
41
+ # ganti koma desimal jika ada
42
+ s = s.replace(",", ".")
43
+ # ambil simbol angka, titik, minus
44
+ cleaned = re.sub(r"[^\d\.\-]", "", s)
45
+ if cleaned in ("", ".", "-", "-.", ".-"):
46
  return 0.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  try:
48
+ return float(cleaned)
49
+ except ValueError:
50
+ return 0.0
51
+
52
+
53
+ def get_grade_from_value(value: float, thresholds: Dict[str, float]) -> str:
54
+ """
55
+ Thresholds contoh: {"A": 1.0, "B": 5.0, "C": 10.0}
56
+ kembalikan "Grade X"
57
+ """
 
 
 
 
 
58
  try:
59
+ if value <= thresholds["A"]:
60
+ return "Grade A"
61
+ if value <= thresholds["B"]:
62
+ return "Grade B"
63
+ if value <= thresholds["C"]:
64
+ return "Grade C"
65
+ return "Grade D"
66
  except Exception:
67
+ return "Grade D"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
 
69
+
70
+ def get_grade_color(grade: str) -> Tuple[str, str]:
71
+ """
72
+ kembalikan (background_color, text_color)
73
+ """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74
  colors = {
75
  "Grade A": ("#2ecc71", "white"),
76
  "Grade B": ("#f1c40f", "black"),
77
  "Grade C": ("#e67e22", "white"),
78
+ "Grade D": ("#e74c3c", "white"),
79
  }
80
  return colors.get(grade, ("#bdc3c7", "black"))
81
 
82
+
83
+ def worse_grade(g1: str, g2: str) -> str:
84
+ """Pilih grade yang 'lebih buruk' menurut urutan Grade A..D"""
85
+ idx1 = GRADE_ORDER.index(g1) if g1 in GRADE_ORDER else len(GRADE_ORDER) - 1
86
+ idx2 = GRADE_ORDER.index(g2) if g2 in GRADE_ORDER else len(GRADE_ORDER) - 1
87
+ return g1 if idx1 > idx2 else g2
88
+
89
+
90
+ # ---------------------------
91
+ # OCR Initialization
92
+ # ---------------------------
 
 
 
 
 
 
 
 
 
 
93
  @st.cache_resource
94
+ def init_paddleocr() -> Optional[PaddleOCR]:
95
+ """Inisialisasi PaddleOCR (cached)."""
 
96
  try:
97
+ # Anda bisa tambahkan lang list sesuai kebutuhan, mis: ['en','id']
98
+ return PaddleOCR(lang="en", use_angle_cls=True)
99
+ except Exception as e:
100
+ st.error(f"Gagal inisialisasi PaddleOCR: {e}")
101
  return None
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
 
104
+ # ---------------------------
105
+ # OCR & Parsing
106
+ # ---------------------------
107
+ def ocr_read_image(ocr_model: PaddleOCR, img_bgr: np.ndarray) -> str:
108
+ """
109
+ Jalankan OCR pada numpy BGR image dan kembalikan teks gabungan (lowercase).
110
+ Jika error, kembalikan string kosong.
111
+ """
112
+ try:
113
+ # PaddleOCR menerima image sebagai numpy array BGR/RGB β€” kami pakai seperti contoh awal
114
+ result = ocr_model.ocr(img_bgr, cls=True)
115
+ # result struktur: list of lists; ambil teks tiap baris
116
+ lines = []
117
+ for block in result:
118
+ for line in block:
119
+ if len(line) >= 2 and isinstance(line[1], (list, tuple)) is False:
120
+ # fallback
121
+ continue
122
+ text = line[1][0] if len(line) >= 2 and isinstance(line[1], (list, tuple)) else ""
123
+ lines.append(str(text))
124
+ return " ".join(lines).lower()
125
+ except Exception as e:
126
+ st.error(f"OCR gagal: {e}")
127
+ return ""
128
+
129
+
130
+ def extract_nutri_from_text(text: str) -> Dict[str, str]:
131
+ """
132
+ Cari nilai serving, sugar, saturated fat dari teks OCR.
133
+ Mengembalikan dict string untuk diisi ulang oleh user jika perlu.
134
+ Pola regex dibuat toleran terhadap kata dalam Bahasa Indonesia & Inggris.
135
+ """
136
+ patterns = {
137
+ "serving": r"(takaran\s*saj(?:i|a)|serving\s*size)[^\d,\.]*([\d\.,]+)",
138
+ "sugar": r"(gula|sugar)[^\d,\.]*([\d\.,]+)",
139
+ "fat": r"(lemak\s*jenuh|saturated\s*fat)[^\d,\.]*([\d\.,]+)",
140
+ }
141
+ found = {}
142
+ for key, pat in patterns.items():
143
+ m = re.search(pat, text, flags=re.IGNORECASE)
144
+ if m:
145
+ found[key] = m.group(2)
146
+ return found
147
+
148
 
149
+ # ---------------------------
150
+ # AI Advice (OpenRouter)
151
+ # ---------------------------
152
+ def get_nutrition_advice_openrouter(api_key: str, serving_size: float, sugar_norm: float, fat_norm: float,
153
+ sugar_grade: str, fat_grade: str, final_grade: str) -> str:
154
+ """
155
+ Panggil OpenRouter Chat Completions untuk meminta saran nutrisi singkat.
156
+ Pastikan API key tersedia (diperoleh dari env / secrets).
157
+ """
158
+ if not api_key:
159
+ return "OpenRouter API key tidak ditemukan. Silakan set OPENROUTER_API_KEY di environment atau streamlit secrets."
160
+ prompt = (
161
+ "Anda adalah ahli gizi dari Indonesia yang ramah.\n"
162
+ f"- Takaran Saji: {serving_size} g/ml\n"
163
+ f"- Gula (per 100): {sugar_norm:.2f} g (Grade {sugar_grade.replace('Grade ', '')})\n"
164
+ f"- Lemak Jenuh (per 100): {fat_norm:.2f} g (Grade {fat_grade.replace('Grade ', '')})\n"
165
+ f"- Grade Akhir: {final_grade.replace('Grade ', '')}\n\n"
166
+ "Berikan saran nutrisi singkat 50-80 kata, fokus pada dampak kesehatan dan tips praktis."
167
+ )
168
+ payload = {
169
+ "model": "mistralai/mistral-7b-instruct:free",
170
+ "messages": [{"role": "user", "content": prompt}],
171
+ "max_tokens": 250,
172
+ "temperature": 0.7,
173
+ }
174
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
175
+ try:
176
+ r = requests.post(f"{OPENROUTER_BASE_URL}/chat/completions", json=payload, headers=headers, timeout=30)
177
+ r.raise_for_status()
178
+ data = r.json()
179
+ return data["choices"][0]["message"]["content"].strip()
180
+ except Exception as e:
181
+ return f"Error saat meminta saran AI: {e}"
182
 
 
 
183
 
184
+ # ---------------------------
185
+ # Streamlit App UI
186
+ # ---------------------------
187
  def reset_state():
188
+ """Reset beberapa key di session state saat user upload file baru."""
189
+ for k in ("ocr_done", "ocr_text", "extracted", "calc", "calculated", "ai_advice"):
190
+ if k in st.session_state:
191
+ del st.session_state[k]
192
+
193
+
194
+ def render_grade_box(col, title: str, value: float, unit: str, grade: str):
195
+ bg, txtc = get_grade_color(grade)
196
+ col.markdown(
197
+ f"<div style='background:{bg};padding:12px;border-radius:10px;text-align:center;color:{txtc};'>"
198
+ f"<strong>{title}</strong>"
199
+ f"<p style='font-size:18px;margin:6px 0'>{value:.2f} {unit}</p>"
200
+ f"<h4 style='margin:2px'>{grade}</h4></div>",
201
+ unsafe_allow_html=True,
202
+ )
203
+
204
+
205
+ def main():
206
+ st.set_page_config(page_title=APP_TITLE, page_icon="πŸ₯—", layout="wide", initial_sidebar_state="collapsed")
207
+ st.title(APP_TITLE)
208
+ st.caption(f"Analisis gizi produk berdasarkan standar Nutri-Grade. {APP_VERSION}")
209
+
210
+ with st.expander("πŸ“‹ Petunjuk Penggunaan"):
211
+ st.markdown(
212
+ "1. Upload gambar (JPG/PNG).\n"
213
+ "2. Klik **Analisis OCR**.\n"
214
+ "3. Koreksi hasil jika diperlukan.\n"
215
+ "4. Klik **Hitung Grade**."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  )
217
+
218
+ ocr_model = init_paddleocr()
219
+ if ocr_model is None:
220
+ st.error("OCR model tidak tersedia. Pastikan PaddleOCR terinstal dengan benar.")
221
+ st.stop()
222
+
223
+ # --- Upload ---
224
+ st.header("1. Upload Gambar")
225
+ uploaded = st.file_uploader("Pilih gambar tabel gizi", type=["jpg", "jpeg", "png"], on_change=reset_state)
226
+ img_bgr = None
227
+ if uploaded:
228
+ # tampilkan preview
229
+ file_bytes = np.frombuffer(uploaded.read(), np.uint8)
230
+ img_bgr = cv2.imdecode(file_bytes, cv2.IMREAD_COLOR)
231
+ if img_bgr is None:
232
+ st.error("Gagal membaca gambar. Pastikan file gambar valid.")
233
+ else:
234
+ st.image(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB), width=320)
235
+
236
+ # tombol OCR
237
+ if uploaded and st.button("Analisis OCR"):
238
+ with st.spinner("Mendeteksi teks..."):
239
+ text = ocr_read_image(ocr_model, img_bgr)
240
+ st.session_state.ocr_text = text
241
+ st.session_state.extracted = extract_nutri_from_text(text)
242
+ st.session_state.ocr_done = True
243
+ st.success("OCR selesai β€” periksa hasil ekstraksi di langkah berikutnya.")
244
+
245
+ # --- Koreksi & Hitung ---
246
+ if st.session_state.get("ocr_done"):
247
+ st.header("2. Koreksi & Hitung Grade")
248
+ extracted = st.session_state.get("extracted", {})
249
+ default_serving = extracted.get("serving", "100")
250
+ default_sugar = extracted.get("sugar", "0")
251
+ default_fat = extracted.get("fat", "0")
252
+
253
+ with st.form("calc_form"):
254
+ serving_input = st.text_input("Takaran Saji (g/ml)", value=default_serving)
255
+ sugar_input = st.text_input("Gula (g) β€” total per serving", value=default_sugar)
256
+ fat_input = st.text_input("Lemak Jenuh (g) β€” total per serving", value=default_fat)
257
+ submitted = st.form_submit_button("Hitung Grade")
258
+
259
+ if submitted:
260
+ sv = safe_float_from_text(serving_input)
261
+ sugar = safe_float_from_text(sugar_input)
262
+ fat = safe_float_from_text(fat_input)
263
+
264
+ # normalisasi ke per 100 g / ml
265
+ sugar_per100 = (sugar / sv) * 100 if sv > 0 else 0.0
266
+ fat_per100 = (fat / sv) * 100 if sv > 0 else 0.0
267
+
268
+ st.session_state.calc = {"serving": sv, "sugar": sugar, "fat": fat,
269
+ "sugar_per100": sugar_per100, "fat_per100": fat_per100}
270
+ st.session_state.calculated = True
271
+
272
+ # --- Tampilkan Hasil ---
273
+ if st.session_state.get("calculated"):
274
+ st.header("3. Hasil Grading")
275
+ c = st.session_state["calc"]
276
+ # thresholds (contoh standar)
277
+ sugar_thresholds = {"A": 1.0, "B": 5.0, "C": 10.0}
278
+ fat_thresholds = {"A": 0.7, "B": 1.2, "C": 2.8}
279
+
280
+ sugar_grade = get_grade_from_value(c["sugar_per100"], sugar_thresholds)
281
+ fat_grade = get_grade_from_value(c["fat_per100"], fat_thresholds)
282
+ final = worse_grade(sugar_grade, fat_grade)
283
+
284
+ cols = st.columns(3, gap="large")
285
+ render_grade_box(cols[0], "Gula", c["sugar_per100"], "g/100", sugar_grade)
286
+ render_grade_box(cols[1], "Lemak Jenuh", c["fat_per100"], "g/100", fat_grade)
287
+ render_grade_box(cols[2], "Grade Akhir", 0.0, "", final)
288
+
289
+ st.markdown("---")
290
+ st.header("4.