YourAIEngineer commited on
Commit
3d87d3a
·
verified ·
1 Parent(s): 178cc73

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +178 -253
app.py CHANGED
@@ -1,290 +1,215 @@
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.
 
1
  import os
2
  import re
 
 
 
3
  import cv2
4
+ import time
5
  import numpy as np
6
  import pandas as pd
7
  import requests
8
  import streamlit as st
 
9
  from PIL import Image
10
+ from paddleocr import PaddleOCR
11
 
12
+ # ==============================
13
+ # KONFIGURASI APLIKASI
14
+ # ==============================
15
+ st.set_page_config(
16
+ page_title="Nutri-Grade Label Detection",
17
+ page_icon="🥗",
18
+ layout="wide",
19
+ initial_sidebar_state="collapsed"
20
+ )
21
+
22
+ # API OpenRouter
23
+ OPENROUTER_API_KEY = "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"
24
  OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
25
 
26
 
27
+ # ==============================
28
+ # FUNGSI UTAMA
29
+ # ==============================
 
 
30
 
 
 
 
 
31
  @st.cache_resource
32
+ def initialize_ocr():
33
+ """Inisialisasi model PaddleOCR (CPU)."""
34
  try:
35
+ return PaddleOCR(lang='en', use_angle_cls=True)
 
36
  except Exception as e:
37
+ st.error(f"Gagal inisialisasi OCR: {e}")
38
  return None
39
 
40
 
41
+ def parse_numeric_value(text: str) -> float:
42
+ """Mengubah string menjadi float, hanya menyisakan angka/desimal."""
43
+ cleaned = re.sub(r"[^\d\.\-]", "", str(text))
44
+ if cleaned in ['', '.', '-']:
45
+ return 0.0
 
 
 
46
  try:
47
+ return float(cleaned)
48
+ except ValueError:
49
+ return 0.0
 
 
 
 
 
 
 
 
 
 
 
 
50
 
51
 
52
+ def get_nutrition_advice(serving_size, sugar_norm, fat_norm, sugar_grade, fat_grade, final_grade):
53
+ """Memanggil API OpenRouter untuk menghasilkan saran nutrisi singkat."""
54
+ prompt = f"""
55
+ Anda adalah ahli gizi dari Indonesia yang ramah.
56
+ - Takaran Saji: {serving_size} g/ml
57
+ - Gula (per 100): {sugar_norm:.2f} g (Grade {sugar_grade.replace('Grade ', '')})
58
+ - Lemak Jenuh (per 100): {fat_norm:.2f} g (Grade {fat_grade.replace('Grade ', '')})
59
+ - Grade Akhir: {final_grade.replace('Grade ', '')}
60
+
61
+ Berikan saran nutrisi singkat 50-80 kata, fokus pada dampak kesehatan dan tips praktis.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
62
  """
63
+ headers = {"Authorization": f"Bearer {OPENROUTER_API_KEY}", "Content-Type": "application/json"}
 
 
 
 
 
 
 
 
 
64
  payload = {
65
  "model": "mistralai/mistral-7b-instruct:free",
66
  "messages": [{"role": "user", "content": prompt}],
67
  "max_tokens": 250,
68
  "temperature": 0.7,
69
  }
 
70
  try:
71
+ r = requests.post(f"{OPENROUTER_BASE_URL}/chat/completions", headers=headers, json=payload, timeout=30)
72
  r.raise_for_status()
73
+ return r.json()["choices"][0]["message"]["content"].strip()
 
74
  except Exception as e:
75
+ return f"Error: {e}"
76
+
77
+
78
+ def get_grade_from_value(value, thresholds):
79
+ """Menentukan grade berdasarkan ambang batas."""
80
+ if value <= thresholds["A"]:
81
+ return "Grade A"
82
+ if value <= thresholds["B"]:
83
+ return "Grade B"
84
+ if value <= thresholds["C"]:
85
+ return "Grade C"
86
+ return "Grade D"
87
+
88
+
89
+ def get_grade_color(grade):
90
+ """Mengembalikan warna background & teks untuk tiap grade."""
91
+ return {
92
+ "Grade A": ("#2ecc71", "white"),
93
+ "Grade B": ("#f1c40f", "black"),
94
+ "Grade C": ("#e67e22", "white"),
95
+ "Grade D": ("#e74c3c", "white")
96
+ }.get(grade, ("#bdc3c7", "black"))
97
 
98
 
 
 
 
99
  def reset_state():
100
+ """Reset session_state agar analisis bisa diulang."""
101
+ for key in ['ocr_done', 'data', 'calculated', 'calc']:
102
+ st.session_state.pop(key, None)
103
+
104
+
105
+ # ==============================
106
+ # UI APLIKASI
107
+ # ==============================
108
+
109
+ # Inisialisasi OCR
110
+ ocr_engine = initialize_ocr()
111
+ if ocr_engine is None:
112
+ st.error("Model OCR tidak tersedia.")
113
+ st.stop()
114
+
115
+ st.title("🥗 Nutri-Grade Detection & Grade Calculator")
116
+ st.caption("Analisis gizi produk berdasarkan standar Nutri-Grade Singapura.")
117
+
118
+ with st.expander("📋 Petunjuk Penggunaan"):
119
+ st.markdown("""
120
+ 1. Upload gambar (JPG/PNG).
121
+ 2. Klik **Analisis OCR**.
122
+ 3. Koreksi hasil jika perlu.
123
+ 4. Klik **Hitung Grade**.
124
+ """)
125
+
126
+ # --- Step 1: Upload ---
127
+ st.header("1. Upload Gambar")
128
+ file = st.file_uploader("Pilih gambar tabel gizi", type=["jpg", "jpeg", "png"], on_change=reset_state)
129
+
130
+ if file:
131
+ arr = np.frombuffer(file.read(), np.uint8)
132
+ img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
133
+ st.image(cv2.cvtColor(img, cv2.COLOR_BGR2RGB), width=300)
134
+
135
+ if st.button("Analisis OCR"):
136
+ with st.spinner("Mendeteksi teks..."):
137
+ res = ocr_engine.ocr(img)
138
+
139
+ texts = [ln[1][0] for ln in (res[0] if res else [])]
140
+ full_text = " ".join(texts).lower()
141
+
142
+ patterns = {
143
+ 'serving': r"(takaran saj[i|a]|serving size)[^\d]*(\d+\.?\d*)",
144
+ 'sugar': r"(gula|sugar)[^\d]*(\d+\.?\d*)",
145
+ 'fat': r"(lemak jenuh|saturated fat)[^\d]*(\d+\.?\d*)"
146
+ }
147
+
148
+ data = {}
149
+ for key, pattern in patterns.items():
150
+ match = re.search(pattern, full_text)
151
+ if match:
152
+ data[key] = match.group(2)
153
+
154
+ st.session_state.data = data
155
+ st.session_state.ocr_done = True
156
+ st.success("OCR selesai!")
157
+ st.rerun()
158
+
159
+ # --- Step 2: Koreksi & Hitung ---
160
+ if st.session_state.get('ocr_done'):
161
+ st.header("2. Koreksi & Hitung Grade")
162
+ d = st.session_state.data
163
+
164
+ with st.form("form2"):
165
+ serving = st.text_input("Takaran Saji (g/ml)", value=d.get('serving', '100'))
166
+ sugar = st.text_input("Gula (g)", value=d.get('sugar', '0'))
167
+ fat = st.text_input("Lemak Jenuh (g)", value=d.get('fat', '0'))
168
+ ok = st.form_submit_button("Hitung Grade")
169
+
170
+ if ok:
171
+ sv = parse_numeric_value(serving)
172
+ sg = parse_numeric_value(sugar)
173
+ fg = parse_numeric_value(fat)
174
+
175
+ sugar_per100 = (sg / sv) * 100 if sv > 0 else 0
176
+ fat_per100 = (fg / sv) * 100 if sv > 0 else 0
177
+
178
+ st.session_state.calc = {'sv': sv, 'sp': sugar_per100, 'fp': fat_per100}
179
+ st.session_state.calculated = True
180
+
181
+ # --- Step 3: Tampilkan Hasil ---
182
+ if st.session_state.get('calculated'):
183
+ c = st.session_state.calc
184
+
185
+ gs = get_grade_from_value(c['sp'], {"A": 1.0, "B": 5.0, "C": 10.0})
186
+ gf = get_grade_from_value(c['fp'], {"A": 0.7, "B": 1.2, "C": 2.8})
187
+
188
+ final_grade = max(gs, gf, key=lambda x: ['Grade A', 'Grade B', 'Grade C', 'Grade D'].index(x))
189
+
190
+ st.header("3. Hasil Grading")
191
+ cols = st.columns(3)
192
+
193
+ def show(col, title, value, unit, grade):
194
+ bg, text_color = get_grade_color(grade)
195
+ col.markdown(
196
+ f"<div style='background:{bg};padding:10px;border-radius:8px;text-align:center;color:{text_color};'>"
197
+ f"<strong>{title}</strong><p>{value:.2f} {unit}</p><h4>{grade}</h4></div>",
198
+ unsafe_allow_html=True
199
  )
200
 
201
+ show(cols[0], "Gula", c['sp'], "g/100ml", gs)
202
+ show(cols[1], "Lemak Jenuh", c['fp'], "g/100ml", gf)
203
+ show(cols[2], "Grade Akhir", 0, "", final_grade)
204
+
205
+ st.divider()
206
+ st.header("4. Saran Nutrisi AI")
207
+
208
+ with st.spinner("Meminta AI..."):
209
+ advice = get_nutrition_advice(c['sv'], c['sp'], c['fp'], gs, gf, final_grade)
210
+
211
+ st.info(advice)
212
+
213
+ # --- Footer ---
214
+ st.markdown("---")
215
+ st.markdown("<p style='text-align:center;'>Nutri-Grade App v2.1 &copy; 2024</p>", unsafe_allow_html=True)