| """ |
| SafeBite — allergen / ingredient label scanner (HF Build Small Hackathon · Backyard AI track) |
| |
| Snap a product's ingredient label, pick your allergens/diet, and SafeBite reads the label |
| on-device (MiniCPM-V 4.6, ~1.3B, Apache-2.0) and flags what to avoid — works in the grocery |
| aisle with no signal, and your health profile never leaves the device. |
| |
| Pipeline (each step logs its I/O to a trace): |
| extract (vision model call) -> normalize (rules) -> match (alias dictionary, rules) |
| -> advise (rules) -> assemble |
| Only the extract step calls the model: a ~1.3B model reads labels well (OCR) but reasons |
| poorly, so allergen matching is a deterministic alias dictionary — safer and transparent. |
| |
| No cloud LLM APIs. Inference path verified against the official openbmb/MiniCPM-V-4.6-Demo: |
| AutoProcessor + MiniCPMV4_6ForConditionalGeneration + apply_chat_template + generate. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import json |
| import re |
| import tempfile |
| import time |
| from dataclasses import dataclass, field |
| from typing import Any |
|
|
| import gradio as gr |
| import spaces |
| import torch |
| from PIL import Image |
| from transformers import AutoProcessor, MiniCPMV4_6ForConditionalGeneration |
|
|
| |
| |
| |
| MODEL_ID = "openbmb/MiniCPM-V-4.6" |
| GPU_DURATION = 60 |
|
|
| print(f"[safebite] loading processor: {MODEL_ID}", flush=True) |
| processor = AutoProcessor.from_pretrained(MODEL_ID, trust_remote_code=True) |
| print(f"[safebite] loading model: {MODEL_ID}", flush=True) |
| model = MiniCPMV4_6ForConditionalGeneration.from_pretrained( |
| MODEL_ID, |
| torch_dtype=torch.bfloat16, |
| attn_implementation="sdpa", |
| trust_remote_code=True, |
| device_map="cuda", |
| ).eval() |
|
|
|
|
| |
| |
| |
| def _run_model(content: list[dict], max_new_tokens: int = 512) -> str: |
| messages = [{"role": "user", "content": content}] |
| has_image = any(item.get("type") == "image" for item in content) |
| inputs = processor.apply_chat_template( |
| messages, |
| add_generation_prompt=True, |
| tokenize=True, |
| return_dict=True, |
| return_tensors="pt", |
| enable_thinking=False, |
| processor_kwargs={ |
| "downsample_mode": "16x", |
| "max_slice_nums": 9 if has_image else 1, |
| "use_image_id": has_image, |
| }, |
| ).to(model.device) |
| for key, value in inputs.items(): |
| if isinstance(value, torch.Tensor) and torch.is_floating_point(value): |
| inputs[key] = value.to(dtype=torch.bfloat16) |
| generated = model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False, downsample_mode="16x") |
| trimmed = [out[len(inp):] for inp, out in zip(inputs.input_ids, generated)] |
| return processor.batch_decode(trimmed, skip_special_tokens=True)[0].strip() |
|
|
|
|
| def _parse_json(raw: str) -> dict | None: |
| """First balanced JSON object, tolerating trailing junk / stray braces.""" |
| start = raw.find("{") |
| if start == -1: |
| return None |
| depth, in_str, esc = 0, False, False |
| for i in range(start, len(raw)): |
| ch = raw[i] |
| if in_str: |
| if esc: |
| esc = False |
| elif ch == "\\": |
| esc = True |
| elif ch == '"': |
| in_str = False |
| elif ch == '"': |
| in_str = True |
| elif ch == "{": |
| depth += 1 |
| elif ch == "}": |
| depth -= 1 |
| if depth == 0: |
| return _loads_lenient(raw[start:i + 1]) |
| return None |
|
|
|
|
| def _loads_lenient(s: str) -> dict | None: |
| try: |
| return json.loads(s) |
| except json.JSONDecodeError: |
| pass |
| |
| fixed = re.sub(r'([\]\}"])\s*[.;]\s*(\n\s*"[^"]+"\s*:)', r"\1,\2", s) |
| try: |
| return json.loads(fixed) |
| except json.JSONDecodeError: |
| return None |
|
|
|
|
| @dataclass |
| class Trace: |
| steps: list[dict] = field(default_factory=list) |
|
|
| def add(self, name: str, inp: Any, out: Any, ms: float) -> None: |
| self.steps.append({"step": name, "input": inp, "output": out, "ms": round(ms, 1)}) |
|
|
| @property |
| def total_ms(self) -> float: |
| return sum(s["ms"] for s in self.steps) |
|
|
|
|
| |
| |
| |
| |
| |
| ALIASES: dict[str, list[str]] = { |
| "Dairy": ["dairy", "milk", "buttermilk", "butterfat", "milk solids", "milkfat", "casein", "caseinate", |
| "caseinates", "sodium caseinate", "whey", "lactose", "ghee", "curd", "cream", |
| "yogurt", "yoghurt", "cheese", "custard"], |
| "Gluten": ["gluten", "wheat", "barley", "rye", "malt", "malted barley", "semolina", "spelt", "farro", |
| "triticale", "durum", "couscous", "bulgur", "seitan", "graham"], |
| "Tree nuts": ["tree nut", "tree nuts", "almond", "cashew", "walnut", "pecan", "hazelnut", "pistachio", |
| "macadamia", "brazil nut", "pine nut", "praline", "nut butter", "marzipan"], |
| "Peanut": ["peanut", "peanuts", "groundnut", "arachis", "peanut butter"], |
| "Egg": ["egg", "eggs", "albumin", "albumen", "ovalbumin", "globulin", "lysozyme", "meringue"], |
| "Soy": ["soy", "soya", "soja", "edamame", "soybean", "tofu", "tempeh", "soy lecithin", "miso"], |
| "Shellfish": ["shellfish", "crustacean", "crustaceans", "shrimp", "prawn", "crab", "lobster", |
| "crayfish", "crawfish", "scampi", "krill", "mollusk", "mollusc"], |
| "Fish": ["fish", "anchovy", "anchovies", "cod", "tuna", "salmon", "haddock", "tilapia", |
| "sardine", "fish sauce", "surimi"], |
| "Sesame": ["sesame", "tahini", "benne", "sesamol", "gingelly"], |
| "Sulfites": ["sulfite", "sulphite", "sulfites", "sulphites", "sodium bisulfite", |
| "potassium metabisulfite", "sulfur dioxide"], |
| "Vegan": ["milk", "buttermilk", "casein", "caseinate", "whey", "lactose", "ghee", "cream", |
| "yogurt", "cheese", "egg", "albumin", "honey", "gelatin", "gelatine", "carmine", |
| "cochineal", "shellac", "rennet", "lard", "tallow", "isinglass", "beeswax", |
| "fish", "anchovy", "meat", "chicken", "beef", "pork"], |
| "Vegetarian": ["gelatin", "gelatine", "rennet", "lard", "tallow", "fish", "anchovy", |
| "fish sauce", "meat", "chicken", "beef", "pork", "isinglass", "carmine", |
| "cochineal"], |
| } |
| PROFILE_OPTIONS = list(ALIASES.keys()) |
| _COMPILED = {cat: re.compile(r"\b(" + "|".join(re.escape(a) for a in sorted(aliases, key=len, reverse=True)) + r")\b", re.I) |
| for cat, aliases in ALIASES.items()} |
|
|
|
|
| |
| |
| |
| EXTRACT_PROMPT = """You are reading a packaged-food label photo. Transcribe it as STRICT JSON, keys exactly: |
| {"product_name": str|null, "ingredients": [str, ...], "allergen_statements": [str, ...]} |
| Rules: |
| - "ingredients" = the items from the INGREDIENTS list, each as a SEPARATE string, split on commas, in order. Keep parenthetical sub-ingredients with their parent (e.g. "chocolate chips (sugar, cocoa, milk)"). |
| - "allergen_statements" = each separate allergen summary line, copied VERBATIM, e.g. "Contains: Milk, Soy", "May contain tree nuts", "Made on shared equipment with peanuts". [] if there are none. |
| - Copy the words exactly as printed; do not add or infer allergens that are not written. |
| Return ONLY the JSON object, no markdown, no commentary.""" |
|
|
|
|
| def step_extract(image: Image.Image, trace: Trace) -> dict | None: |
| start = time.time() |
| content = [{"type": "image", "image": image.convert("RGB")}, |
| {"type": "text", "text": EXTRACT_PROMPT}] |
| raw = _run_model(content, max_new_tokens=512) |
| parsed = _parse_json(raw) |
| trace.add("extract", {"has_image": True}, parsed if parsed is not None else {"_raw": raw}, |
| (time.time() - start) * 1000) |
| return parsed |
|
|
|
|
| |
| |
| |
| def _as_list(value: Any) -> list[str]: |
| if isinstance(value, list): |
| return [str(v).strip() for v in value if str(v).strip()] |
| if isinstance(value, str) and value.strip(): |
| return [p.strip() for p in re.split(r"[;,\n]", value) if p.strip()] |
| return [] |
|
|
|
|
| |
| |
| _MAY_RE = re.compile(r"may contain|may be present|may include|traces? of|" |
| r"made (on|in)|produced (on|in)|packed (on|in)|processed (on|in)|" |
| r"shared (equipment|facility|line)|same (equipment|facility|line)", re.I) |
|
|
|
|
| def step_normalize(parsed: dict, trace: Trace) -> dict: |
| start = time.time() |
| statements = _as_list(parsed.get("allergen_statements")) |
| |
| statements += _as_list(parsed.get("contains")) + [f"may contain {x}" for x in _as_list(parsed.get("may_contain"))] |
| contains, may_contain = [], [] |
| for s in statements: |
| (may_contain if _MAY_RE.search(s) else contains).append(s) |
| clean = { |
| "product_name": (parsed.get("product_name") or "").strip() or None, |
| "ingredients": _as_list(parsed.get("ingredients")), |
| "contains": contains, |
| "may_contain": may_contain, |
| "statements": statements, |
| } |
| trace.add("normalize", parsed, clean, (time.time() - start) * 1000) |
| return clean |
|
|
|
|
| |
| |
| |
| def step_match(facts: dict, profile: list[str], trace: Trace) -> list[dict]: |
| start = time.time() |
| buckets = { |
| "ingredients": " , ".join(facts["ingredients"]), |
| "contains": " , ".join(facts["contains"]), |
| "may_contain": " , ".join(facts["may_contain"]), |
| } |
| hits: list[dict] = [] |
| seen = set() |
| for cat in profile: |
| rx = _COMPILED.get(cat) |
| if not rx: |
| continue |
| for source, text in buckets.items(): |
| for matched in {m.lower() for m in rx.findall(text)}: |
| key = (cat, matched, source) |
| if key in seen: |
| continue |
| seen.add(key) |
| hits.append({"allergen": cat, "matched": matched, "source": source}) |
| trace.add("match", {"profile": profile, "buckets": {k: bool(v) for k, v in buckets.items()}}, |
| hits, (time.time() - start) * 1000) |
| return hits |
|
|
|
|
| |
| |
| |
| def step_advise(hits: list[dict], trace: Trace) -> dict: |
| start = time.time() |
| direct = [h for h in hits if h["source"] in ("ingredients", "contains")] |
| trace_only = [h for h in hits if h["source"] == "may_contain"] |
| if direct: |
| tier, banner = "avoid", "🔴 AVOID" |
| elif trace_only: |
| tier, banner = "caution", "🟠 CAUTION" |
| else: |
| tier, banner = "clear", "🟢 No flagged allergens found" |
| out = {"tier": tier, "banner": banner} |
| trace.add("advise", {"direct": len(direct), "may_contain": len(trace_only)}, out, (time.time() - start) * 1000) |
| return out |
|
|
|
|
| |
| |
| |
| DISCLAIMER = ( |
| "_Not medical advice and not a substitute for reading the physical label. AI reads labels " |
| "from a single photo on a ~1.3B on-device model and can miss or misread text. If you have a " |
| "severe allergy, always verify on the package itself._" |
| ) |
| SOURCE_LABEL = {"ingredients": "Ingredients list", "contains": "“Contains” statement", "may_contain": "“May contain” / facility"} |
|
|
|
|
| def _hits_table(hits: list[dict]) -> str: |
| rows = ["| Allergen / diet | Matched word | Found in |", "| --- | --- | --- |"] |
| order = {"ingredients": 0, "contains": 1, "may_contain": 2} |
| for h in sorted(hits, key=lambda x: order.get(x["source"], 3)): |
| rows.append(f"| {h['allergen']} | `{h['matched']}` | {SOURCE_LABEL.get(h['source'], h['source'])} |") |
| return "\n".join(rows) |
|
|
|
|
| def _render(facts: dict, hits: list[dict], advice: dict, profile: list[str], trace: Trace) -> str: |
| name = facts.get("product_name") or "this product" |
| parts = ["## 🥫 SafeBite — verdict", f"### {advice['banner']}"] |
|
|
| if advice["tier"] == "avoid": |
| parts.append(f"**{name}** contains something on your list — don't eat it without checking the package.") |
| elif advice["tier"] == "caution": |
| parts.append(f"**{name}** has a *may-contain* / shared-facility warning for your allergens — risky if you're sensitive.") |
| else: |
| parts.append(f"No items from your selected list were found in **{name}**. Still verify on the package.") |
|
|
| if hits: |
| parts.append("### 🚩 Flagged\n" + _hits_table(hits)) |
|
|
| if facts["ingredients"]: |
| shown = ", ".join(facts["ingredients"][:40]) |
| parts.append("### 📋 Ingredients read\n" + shown) |
| def _strip_label(s: str) -> str: |
| return re.sub(r"^(contains|may contain|may also contain|may be present)\s*:?\s*", "", s, flags=re.I).strip() |
|
|
| for label, key in (("Contains", "contains"), ("May contain", "may_contain")): |
| if facts[key]: |
| cleaned = [c for c in (_strip_label(s) for s in facts[key]) if c] |
| if cleaned: |
| parts.append(f"**{label}:** {', '.join(cleaned)}") |
|
|
| parts.append(f"<sub>checked for: {', '.join(profile)} · pipeline: {' → '.join(s['step'] for s in trace.steps)} · {trace.total_ms:.0f} ms</sub>") |
| parts.append(DISCLAIMER) |
| return "\n\n".join(parts) |
|
|
|
|
| |
| |
| |
| def analyze(image: Image.Image | None, profile: list[str] | None) -> tuple[str, list]: |
| if image is None: |
| return "⚠️ Upload or snap a photo of the product's ingredient label to get started.", [] |
| if not profile: |
| return "⚠️ Pick at least one allergen or diet to check against, then scan again.", [] |
|
|
| trace = Trace() |
| parsed = step_extract(image, trace) |
| if parsed is None or not _as_list(parsed.get("ingredients")) and not _as_list(parsed.get("allergen_statements")): |
| raw = trace.steps[-1]["output"].get("_raw", "") if parsed is None else "" |
| body = f"\n\nRaw model output:\n\n```\n{raw}\n```" if raw else "" |
| return ("## 🥫 SafeBite\n\nI couldn't read an ingredients list in that photo. Try a sharper, " |
| "well-lit close-up of the label." + body + "\n\n" + DISCLAIMER), trace.steps |
|
|
| facts = step_normalize(parsed, trace) |
| hits = step_match(facts, profile, trace) |
| advice = step_advise(hits, trace) |
| return _render(facts, hits, advice, profile, trace), trace.steps |
|
|
|
|
| def _trace_file(steps: list) -> str | None: |
| """Write the run's pipeline trace to a downloadable JSON (Open Trace).""" |
| if not steps: |
| return None |
| payload = {"app": "safebite", "model": MODEL_ID, "pipeline": [s["step"] for s in steps], "steps": steps} |
| handle = tempfile.NamedTemporaryFile(mode="w", suffix="_safebite-trace.json", delete=False, encoding="utf-8") |
| json.dump(payload, handle, indent=2, ensure_ascii=False) |
| handle.close() |
| return handle.name |
|
|
|
|
| @spaces.GPU(duration=GPU_DURATION) |
| def analyze_gpu(image: Image.Image | None, profile: list[str] | None) -> tuple[str, str | None]: |
| markdown, steps = analyze(image, profile) |
| return markdown, _trace_file(steps) |
|
|
|
|
| |
| |
| |
| EXAMPLES = [ |
| ["assets/examples/label_granola.png", ["Peanut", "Dairy"]], |
| ["assets/examples/label_cookie.png", ["Vegan"]], |
| ["assets/examples/label_crackers.png", ["Tree nuts"]], |
| ] |
|
|
|
|
| def build_ui() -> gr.Blocks: |
| with gr.Blocks(title="SafeBite", theme=gr.themes.Soft()) as demo: |
| gr.Markdown( |
| "# 🥫 SafeBite\n" |
| "Snap a product's **ingredient label**, pick what you avoid, and SafeBite reads it " |
| "**on-device** on MiniCPM-V 4.6 and flags what to skip. Works in the aisle with no " |
| "signal — and your health profile never leaves the device. _No cloud APIs._" |
| ) |
| with gr.Row(): |
| with gr.Column(scale=1): |
| image_in = gr.Image(type="pil", label="Ingredient label photo", height=340) |
| profile_in = gr.CheckboxGroup(PROFILE_OPTIONS, label="I need to avoid…", value=["Peanut", "Dairy"]) |
| run_btn = gr.Button("Check label", variant="primary") |
| gr.Examples(examples=EXAMPLES, inputs=[image_in, profile_in], label="Try an example") |
| with gr.Column(scale=1): |
| verdict = gr.Markdown("Your verdict will appear here.") |
| trace_file = gr.File(label="⬇️ Agent trace (JSON) — Open Trace", interactive=False) |
|
|
| run_btn.click(analyze_gpu, inputs=[image_in, profile_in], outputs=[verdict, trace_file]) |
| return demo |
|
|
|
|
| if __name__ == "__main__": |
| build_ui().launch() |
|
|