Spaces:
Build error
Build error
Update app.py
Browse files
app.py
CHANGED
@@ -1,383 +1,290 @@
|
|
1 |
-
|
2 |
-
import
|
|
|
|
|
|
|
3 |
import cv2
|
4 |
import numpy as np
|
5 |
-
import
|
|
|
|
|
|
|
6 |
from PIL import Image
|
7 |
-
|
8 |
-
|
9 |
-
|
10 |
-
#
|
11 |
-
|
12 |
-
|
13 |
-
|
14 |
-
|
15 |
-
|
16 |
-
#
|
17 |
-
#
|
18 |
-
#
|
19 |
-
|
20 |
-
|
21 |
-
|
22 |
-
|
23 |
-
|
24 |
-
)
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
"
|
29 |
-
|
30 |
-
|
31 |
-
"
|
32 |
-
|
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 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
if
|
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 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
88 |
except Exception:
|
89 |
-
|
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 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
|
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 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
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
|
263 |
-
|
264 |
-
return None
|
265 |
try:
|
266 |
-
|
267 |
-
|
|
|
|
|
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 |
-
|
289 |
-
|
290 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
291 |
|
292 |
-
|
293 |
-
|
294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
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 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
st.
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
382 |
-
|
383 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|