| """Matter β Material Intelligence Platform Β· Gradio Space app. |
| |
| Scene-mode: upload an image with one or many objects β Gemma 4 detects each |
| with a bounding box β MIE pipeline runs per-detection β one Passport per |
| object, with full per-layer trace + verifier scoring. |
| |
| Gemma 4 E2B loads lazily on first inference. ZeroGPU spins up only when |
| generating; cold start β30 s, warm latency ~8β18 s depending on object count. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import html |
| import json |
| import tempfile |
| import traceback |
| from pathlib import Path |
|
|
| import gradio as gr |
| from PIL import Image, ImageDraw, ImageFont, ImageOps |
|
|
| from matter.engine import MIE, CaptureInput, MIEError |
| from matter.heads import HEADS |
| from matter.verifier import Verifier |
| from transformers_runtime import TransformersRuntime |
|
|
| verifier = Verifier() |
|
|
| |
| |
| BBOX_COLORS: dict[str, str] = { |
| "plastic_bottle": "#00d97e", |
| "multilayer_plastic": "#00e5ff", |
| "carton": "#ffb547", |
| "metal_can": "#7dd3a8", |
| "organic": "#a5e8ff", |
| "glass": "#ff9bcc", |
| "paper": "#d2efe0", |
| "other": "#8aa79b", |
| "sharps": "#ff6b6b", |
| "diagnostic": "#ff6b6b", |
| "lithium_ion_cell": "#ffb547", |
| "battery_pack": "#ffb547", |
| "lead_acid_battery": "#ff6b6b", |
| } |
| DEFAULT_BBOX_COLOR = "#00d97e" |
|
|
| ROOT = Path(__file__).parent |
| EXAMPLES_DIR = ROOT / "examples" |
|
|
| HEAD_NAMES = list(HEADS.keys()) |
|
|
| SAMPLE_IMAGES: dict[str, str] = { |
| "domestic": "domestic_pet_bottle.jpg", |
| "ewaste": "ewaste_dead_laptop.jpg", |
| "ev": "ev_pouch_cell.jpg", |
| "medical": "medical_glucose_strip.jpg", |
| "cd": "cd_brick.jpg", |
| "textile": "textile_cotton_tshirt.jpg", |
| } |
|
|
|
|
| _runtime: TransformersRuntime | None = None |
|
|
|
|
| def get_engine() -> MIE: |
| global _runtime |
| if _runtime is None: |
| _runtime = TransformersRuntime() |
| return MIE(runtime=_runtime, on_device=True) |
|
|
|
|
| |
| |
| |
|
|
| def render_bbox_overlay(image_path: str, passports: list) -> Image.Image: |
| """Draw colored bboxes + numeric labels over the input image.""" |
| img = Image.open(image_path).convert("RGB") |
| draw = ImageDraw.Draw(img, "RGBA") |
| W, H = img.size |
|
|
| |
| try: |
| font = ImageFont.truetype("DejaVuSans-Bold.ttf", max(14, int(W * 0.020))) |
| except Exception: |
| font = ImageFont.load_default() |
|
|
| for i, p in enumerate(passports, start=1): |
| bbox = p.identity.bbox |
| if not bbox or len(bbox) != 4: |
| continue |
| x1, y1, x2, y2 = (max(0.0, min(1.0, c)) for c in bbox) |
| |
| x1, x2 = sorted([x1, x2]); y1, y2 = sorted([y1, y2]) |
| px = [x1 * W, y1 * H, x2 * W, y2 * H] |
|
|
| cls = p.identity.class_ |
| color = BBOX_COLORS.get(cls, DEFAULT_BBOX_COLOR) |
|
|
| |
| draw.rectangle(px, outline=color, width=3) |
| fill_rgba = _hex_to_rgba(color, alpha=24) |
| draw.rectangle(px, fill=fill_rgba) |
|
|
| |
| label = f"{i} Β· {cls} {p.identity.confidence:.2f}" |
| |
| bbox_text = draw.textbbox((px[0] + 6, px[1] + 4), label, font=font) |
| pad = 4 |
| draw.rectangle( |
| [bbox_text[0] - pad, bbox_text[1] - pad, |
| bbox_text[2] + pad, bbox_text[3] + pad], |
| fill=(4, 19, 12, 220), |
| outline=color, |
| width=1, |
| ) |
| draw.text((px[0] + 6, px[1] + 4), label, fill=color, font=font) |
| return img |
|
|
|
|
| def _hex_to_rgba(hex_color: str, alpha: int) -> tuple[int, int, int, int]: |
| h = hex_color.lstrip("#") |
| return (int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16), alpha) |
|
|
|
|
| |
| |
| |
|
|
| |
| |
| |
| |
| MAX_IMAGE_DIM = 2048 |
| MIN_IMAGE_DIM = 64 |
|
|
|
|
| def preprocess_image(image_path: str) -> str: |
| """Sanitize an uploaded image before pipeline ingestion. |
| |
| - Apply EXIF rotation (iPhone photos arrive sideways otherwise) |
| - Force RGB (drop alpha, paletted formats, multi-frame indices) |
| - Reject tiny inputs (< MIN_IMAGE_DIM on shortest edge) |
| - Downscale anything over MAX_IMAGE_DIM on the longest edge |
| - Re-encode to JPEG in a temp file, returning the new path |
| |
| Re-encoding through PIL also strips polyglot-attack payloads (a file that |
| pretends to be a PNG but contains JS) since we never serve the user-supplied |
| bytes back to the browser. |
| |
| Raises ValueError for inputs we refuse to process. |
| """ |
| img = Image.open(image_path) |
| img = ImageOps.exif_transpose(img) |
| if img.mode != "RGB": |
| img = img.convert("RGB") |
|
|
| w, h = img.size |
| if min(w, h) < MIN_IMAGE_DIM: |
| raise ValueError( |
| f"Image is too small ({w}Γ{h}). Need at least {MIN_IMAGE_DIM}px on the shortest edge." |
| ) |
|
|
| longest = max(w, h) |
| if longest > MAX_IMAGE_DIM: |
| scale = MAX_IMAGE_DIM / longest |
| new_size = (int(w * scale), int(h * scale)) |
| img = img.resize(new_size, Image.LANCZOS) |
|
|
| out = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) |
| img.save(out.name, "JPEG", quality=92, optimize=True) |
| out.close() |
| return out.name |
|
|
|
|
| def safe(s: object) -> str: |
| """HTML-escape a model-emitted string before splicing into HTML output. |
| |
| Gemma's free-text fields (reason, subclass, bbox_label) are user-influenced |
| via the input image β an attacker can craft a photo with text like |
| `<script>...</script>` and induce the model to repeat it. Without escaping, |
| that lands as live HTML in the action card. |
| """ |
| if s is None: |
| return "" |
| return html.escape(str(s), quote=True) |
|
|
|
|
| |
| |
| |
|
|
|
|
|
|
|
|
| |
| |
| |
|
|
| |
| |
| CLASS_LOOKS: dict[str, tuple[str, str]] = { |
| |
| "plastic_bottle": ("β»οΈ", "Plastic bottle"), |
| "multilayer_plastic": ("π₯‘", "Flexible plastic / pouch"), |
| "carton": ("π¦", "Carton"), |
| "metal_can": ("π₯«", "Metal can"), |
| "organic": ("πΏ", "Food waste"), |
| "glass": ("π«", "Glass"), |
| "paper": ("π", "Paper"), |
| "other": ("ποΈ", "Mixed waste"), |
| |
| "laptop": ("π»", "Laptop"), |
| "smartphone": ("π±", "Smartphone"), |
| "cable": ("π", "Cable"), |
| "power_adapter": ("π", "Power adapter"), |
| "audio": ("π§", "Audio device"), |
| "battery": ("π", "Battery"), |
| "pcb": ("π§", "Circuit board"), |
| "lighting": ("π‘", "Lighting"), |
| |
| "lithium_ion_cell": ("π", "Li-ion cell"), |
| "lead_acid_battery": ("π", "Lead-acid battery"), |
| "battery_pack": ("π", "Battery pack"), |
| "connector": ("π", "Connector"), |
| |
| "sharps": ("π", "Syringe / sharps"), |
| "diagnostic": ("π§ͺ", "Diagnostic strip"), |
| "medicine_bottle": ("π", "Medicine bottle"), |
| "blister_pack": ("π", "Blister pack"), |
| "wound_care": ("π©Ή", "Wound-care item"), |
| "packaging": ("π¦", "Medical packaging"), |
| "device": ("π©Ί", "Medical device"), |
| |
| "concrete": ("π§±", "Concrete"), |
| "drywall": ("π§±", "Drywall"), |
| "wood": ("πͺ΅", "Wood"), |
| "rebar": ("π©", "Rebar"), |
| "tile": ("π«", "Tile"), |
| "insulation": ("βοΈ", "Insulation"), |
| "pvc": ("π°", "PVC pipe"), |
| |
| "denim": ("π", "Denim"), |
| "cotton": ("π", "Cotton textile"), |
| "polyester": ("π§₯", "Synthetic textile"), |
| "wool": ("π§Ά", "Wool"), |
| } |
|
|
| |
| ACTION_VERBS: dict[str, tuple[str, str, str]] = { |
| "blue_bin_recycle": ("Put in the BLUE recycling bin", "Recycle", "#00d97e"), |
| "compost_bin": ("Put in the GREEN compost bin", "Compost", "#7dd3a8"), |
| "general_waste": ("Put in the BLACK general waste bin", "General waste", "#8aa79b"), |
| "special_collection": ("Take to a special collection event", "Special collection", "#00e5ff"), |
| "biomedical_waste_collector": ("Take to a biomedical waste collector", "Biomedical waste", "#ff6b6b"), |
| "pharmacy_takeback": ("Drop at a pharmacy take-back box", "Pharmacy take-back", "#00e5ff"), |
| "battery_drop_off": ("Drop at a battery collection point", "Battery drop-off", "#ffb547"), |
| "ewaste_collection_event": ("Take to the next e-waste event", "E-waste event", "#00e5ff"), |
| "retailer_takeback": ("Return to the retailer", "Retailer take-back", "#00e5ff"), |
| "second_life_stationary_storage": ("Eligible for second-life energy storage", "Second life", "#7dd3a8"), |
| "certified_ev_recycler": ("Take to a certified EV battery recycler", "Certified recycler", "#ffb547"), |
| "aggregate_recycler": ("Take to an aggregate / C&D recycler", "Aggregate recycler", "#00d97e"), |
| "fiber_recycler": ("Donate or take to a textile recycler", "Fiber recycler", "#00d97e"), |
| "resale_reuse": ("Donate or resell β still has value", "Reuse", "#00d97e"), |
| "landfill": ("Last resort β landfill", "Landfill", "#8aa79b"), |
| "recycle_paper": ("Put in paper recycling", "Paper recycle", "#00d97e"), |
| } |
|
|
| CANONICAL_HAZARDS = { |
| "biohazard", "sharps_injury_risk", "thermal_runaway_risk", |
| "lead_toxicity", "acid_corrosion", |
| } |
|
|
|
|
| def _action_label(action_id: str) -> tuple[str, str, str]: |
| """Return (verb_sentence, badge_label, accent_hex). Fallback for unknown actions.""" |
| return ACTION_VERBS.get(action_id, ( |
| f"Route to {action_id.replace('_', ' ')}", |
| action_id.replace("_", " ").title(), |
| "#00d97e", |
| )) |
|
|
|
|
| def _class_look(cls: str) -> tuple[str, str]: |
| return CLASS_LOOKS.get(cls, ("ποΈ", cls.replace("_", " ").title())) |
|
|
|
|
| def render_kpi_strip(passports: list, scene_trace: dict) -> str: |
| """Four-card KPI banner: items, CO2e, hazards caught, jurisdiction.""" |
| if not passports: |
| return "" |
|
|
| n = len(passports) |
| total_co2 = 0.0 |
| for p in passports: |
| env = p.value.environmental if p.value and p.value.environmental else None |
| if env and env.co2e_avoided_kg is not None: |
| total_co2 += env.co2e_avoided_kg |
|
|
| hazards_caught = 0 |
| for d in scene_trace.get("detections", []): |
| if not isinstance(d, dict) or d.get("error"): |
| continue |
| fired = d.get("guardrail", {}).get("fired") |
| added = d.get("hazards", {}).get("added") |
| if fired or added: |
| hazards_caught += 1 |
|
|
| juris = scene_trace.get("metadata", {}).get("jurisdiction", "") |
| juris_short = juris.split(" (")[0].strip() or "β" |
|
|
| |
| |
| heads_seen = scene_trace.get("metadata", {}).get("heads_seen") or [] |
| if heads_seen: |
| if len(heads_seen) == 1: |
| tile_emoji = "π" |
| tile_value = DOMAIN_LABELS.get(heads_seen[0], heads_seen[0].title()) |
| tile_label = "domain" |
| else: |
| tile_emoji = "π" |
| tile_value = ", ".join(DOMAIN_LABELS.get(h, h.title()) for h in heads_seen) |
| tile_label = "domains involved" |
| else: |
| tile_emoji = "π" |
| tile_value = juris_short |
| tile_label = "jurisdiction" |
|
|
| co2_class = "kpi-num" |
| co2_color = "" if total_co2 >= 0 else 'style="color:#ffb547;"' |
| hazard_class = "kpi-card kpi-card-alert" if hazards_caught else "kpi-card" |
| hazard_emoji = "β οΈ" if hazards_caught else "β" |
|
|
| return ( |
| '<div class="kpi-strip">' |
| + f'<div class="kpi-card"><div class="kpi-emoji">π¦</div>' |
| f'<div class="kpi-num">{n}</div>' |
| f'<div class="kpi-label">{"item" if n == 1 else "items"} processed</div></div>' |
| + f'<div class="kpi-card"><div class="kpi-emoji">π±</div>' |
| f'<div class="{co2_class}" {co2_color}>{total_co2:.3f}<span class="kpi-unit">kg</span></div>' |
| f'<div class="kpi-label">COβe avoided</div></div>' |
| + f'<div class="{hazard_class}"><div class="kpi-emoji">{hazard_emoji}</div>' |
| f'<div class="kpi-num">{hazards_caught}</div>' |
| f'<div class="kpi-label">{"hazard caught" if hazards_caught == 1 else "hazards caught"}</div></div>' |
| + f'<div class="kpi-card"><div class="kpi-emoji">{tile_emoji}</div>' |
| f'<div class="kpi-num kpi-num-small">{safe(tile_value)}</div>' |
| f'<div class="kpi-label">{safe(tile_label)}</div></div>' |
| + '</div>' |
| ) |
|
|
|
|
| def render_action_cards(passports: list, scene_trace: dict) -> str: |
| """Per-detection card list. Hazard variant for items the guardrail caught |
| or that carry canonical hazard flags.""" |
| if not passports: |
| return ( |
| '<div class="empty-state">' |
| "π <strong>No recognizable items detected.</strong><br>" |
| "Try a clearer image, or pick a different material domain on the left." |
| "</div>" |
| ) |
|
|
| detections_map: dict[str, dict] = { |
| d.get("passport_id"): d for d in scene_trace.get("detections", []) |
| if isinstance(d, dict) and d.get("passport_id") |
| } |
|
|
| cards = [] |
| for i, p in enumerate(passports, start=1): |
| det = detections_map.get(p.passport_id) |
| cards.append(_render_action_card(i, p, det)) |
| return "\n".join(cards) |
|
|
|
|
| DOMAIN_LABELS: dict[str, str] = { |
| "domestic": "Domestic", |
| "ewaste": "E-waste", |
| "ev": "EV battery", |
| "medical": "Medical", |
| "cd": "C&D", |
| "textile": "Textile", |
| } |
|
|
|
|
| def _render_action_card(idx: int, p, det: dict | None) -> str: |
| cls = p.identity.class_ |
| emoji, display_name = _class_look(cls) |
| primary = p.next_best_action.primary |
| verb, bin_label, accent = _action_label(primary) |
| confidence_pct = int(round(p.identity.confidence * 100)) |
| head = (det or {}).get("head") or _head_from_taxonomy(p.identity.taxonomy) or "" |
| domain_label = DOMAIN_LABELS.get(head, head.title() if head else "") |
| |
| |
|
|
| hazards = list(p.state.hazard_flags or []) |
| is_canonical_hazard = any(h in CANONICAL_HAZARDS for h in hazards) |
| severity = (det or {}).get("guardrail", {}).get("severity") |
| fired = (det or {}).get("guardrail", {}).get("fired", False) or p.next_best_action.fallback_used |
| is_alert = is_canonical_hazard or fired |
|
|
| reason = (det or {}).get("reason") or p.identity.subclass or "" |
| reason = reason.strip() |
| if reason: |
| |
| |
| |
| reason = reason[:200] |
| reason = reason[0].upper() + reason[1:] |
|
|
| env = p.value.environmental if p.value and p.value.environmental else None |
| co2 = env.co2e_avoided_kg if env else None |
| co2_str = "" |
| if co2 is not None and abs(co2) >= 0.0001: |
| sign = "+" if co2 > 0 else "" |
| co2_str = f"π± {sign}{co2:.4f} kg COβe" |
|
|
| if is_alert: |
| do_not_pretty = ", ".join( |
| _action_label(a)[1].lower() for a in (p.next_best_action.do_not or []) |
| ) or "general waste" |
| sev_label = (severity or "high").upper() |
| |
| |
| |
| |
| return ( |
| f'<div class="action-card action-card-hazard" data-idx="{int(idx)}">' |
| f' <div class="card-header">' |
| f' <div class="card-title">' |
| f' <span class="card-num">{int(idx)}</span>' |
| f' <span class="card-emoji">{safe(emoji)}</span>' |
| f' <span class="card-name">{safe(display_name)}</span>' |
| + (f' <span class="domain-pill">{safe(domain_label)}</span>' if domain_label else '') |
| + f' </div>' |
| f' <div class="card-badge badge-hazard">β οΈ Hazard Β· {safe(sev_label)}</div>' |
| f' </div>' |
| f' <div class="card-body">' |
| f' <div class="hazard-row hazard-do-not"><strong>DO NOT</strong> {safe(do_not_pretty)}</div>' |
| f' <div class="hazard-row hazard-do"><strong>TAKE TO</strong> {safe(bin_label.lower())}</div>' |
| + (f' <div class="card-reason">Why this matters: {safe(reason)}</div>' if reason else '') |
| + f' <div class="card-meta">' |
| f' <div class="card-confidence">' |
| f' <div class="conf-label">Confidence</div>' |
| f' <div class="conf-bar"><div class="conf-fill" style="width:{int(confidence_pct)}%;"></div></div>' |
| f' <div class="conf-pct">{int(confidence_pct)}%</div>' |
| f' </div>' |
| + (f' <span class="card-co2">{safe(co2_str)}</span>' if co2_str else '') |
| + f' </div>' |
| f' </div>' |
| f'</div>' |
| ) |
|
|
| return ( |
| f'<div class="action-card" data-idx="{int(idx)}">' |
| f' <div class="card-header">' |
| f' <div class="card-title">' |
| f' <span class="card-num">{int(idx)}</span>' |
| f' <span class="card-emoji">{safe(emoji)}</span>' |
| f' <span class="card-name">{safe(display_name)}</span>' |
| + (f' <span class="domain-pill">{safe(domain_label)}</span>' if domain_label else '') |
| + f' </div>' |
| + f' <div class="card-badge" style="background:linear-gradient(135deg,{accent}33,{accent}11);' |
| f'border:1px solid {accent}55;color:{accent};">{safe(bin_label)}</div>' |
| f' </div>' |
| f' <div class="card-body">' |
| f' <div class="card-action">β {safe(verb)}</div>' |
| + (f' <div class="card-reason">{safe(reason)}</div>' if reason else '') |
| + f' <div class="card-meta">' |
| f' <div class="card-confidence">' |
| f' <div class="conf-label">Confidence</div>' |
| f' <div class="conf-bar"><div class="conf-fill" style="width:{int(confidence_pct)}%;"></div></div>' |
| f' <div class="conf-pct">{int(confidence_pct)}%</div>' |
| f' </div>' |
| + (f' <span class="card-co2">{safe(co2_str)}</span>' if co2_str else '') |
| + f' </div>' |
| f' </div>' |
| f'</div>' |
| ) |
|
|
|
|
| def render_technical_details(passports: list, scene_trace: dict) -> str: |
| """One markdown blob with everything an engineer / judge wants: |
| pipeline trace per detection, verifier scoring, raw model output.""" |
| if not passports: |
| return "" |
|
|
| detections_map = { |
| d.get("passport_id"): d for d in scene_trace.get("detections", []) |
| if isinstance(d, dict) and d.get("passport_id") |
| } |
|
|
| sections = ["### Pipeline trace (per detection)", ""] |
| for i, p in enumerate(passports, start=1): |
| det = detections_map.get(p.passport_id) |
| if not det: |
| continue |
| c = det["calibration"] |
| h = det["hazards"] |
| g = det["guardrail"] |
| delta = c["calibrated"]["identity"] - c["raw"]["identity"] |
| sections.append(f"**{i}. `{p.identity.class_}`** Β· _{p.identity.subclass or ''}_") |
| sections.append(f"- **Calibration** ({c['method']}): identity " |
| f"`{c['raw']['identity']:.3f}` β `{c['calibrated']['identity']:.3f}` " |
| f"(`{delta:+.3f}`)") |
| sections.append(f"- **Hazard auto-flagger**: model said " |
| f"{', '.join('`' + x + '`' for x in h['before']) if h['before'] else 'β
'}, " |
| f"added {', '.join('`' + x + '`' for x in h['added']) if h['added'] else 'β
'}") |
| if g["fired"]: |
| sections.append(f"- **Guardrail**: β οΈ fired (severity `{g['severity']}`) β " |
| f"`{g['proposed_action']}` overridden to `{g['safe_default']}`") |
| else: |
| sections.append(f"- **Guardrail**: β
proposed `{g['proposed_action']}` passed all `do_not` rules") |
| sections.append("") |
|
|
| |
| sections.append("### Verifier scoring") |
| sections.append("") |
| sections.append("| # | class | structural | json | enum | do_not | hazards |") |
| sections.append("|---|---|---:|:---:|:---:|:---:|:---:|") |
| for i, p in enumerate(passports, start=1): |
| head = _head_from_taxonomy(p.identity.taxonomy) |
| if not head: |
| continue |
| raw = json.dumps({ |
| "identity": {"class": p.identity.class_, "confidence": p.identity.confidence}, |
| "state": {"hazard_flags": list(p.state.hazard_flags or [])}, |
| "next_best_action": {"primary": p.next_best_action.primary}, |
| }) |
| s = verifier.score(raw, head, ground_truth=None) |
| sections.append( |
| f"| {i} | `{p.identity.class_}` | `{s.structural:.3f}` | " |
| f"{'β
' if s.json_valid else 'β'} | " |
| f"{'β
' if s.enum_valid else 'β'} | " |
| f"{'β
' if s.do_not_compliance else 'β'} | " |
| f"{'β
' if s.hazard_completeness else 'β'} |" |
| ) |
|
|
| |
| raw = scene_trace.get("raw_output", "") |
| sections.append("") |
| sections.append("### Gemma 4 raw scene output") |
| sections.append("") |
| truncated = raw[:1500] + ("\n... (truncated)" if len(raw) > 1500 else "") |
| sections.append(f"```\n{truncated}\n```") |
| return "\n".join(sections) |
|
|
|
|
| def _head_from_taxonomy(uri: str | None) -> str | None: |
| """Reverse-lookup head name from `https://matter.spec/taxonomy/<head>/v0.1`.""" |
| if not uri: |
| return None |
| parts = uri.rstrip("/").split("/") |
| return parts[-2] if len(parts) >= 2 else None |
|
|
|
|
| |
| |
| |
|
|
| def run(image_path: str | None, head: str, jurisdiction: str = "") -> tuple: |
| """Scene-mode inference for the selected head. |
| |
| Returns (annotated_image, kpi_strip, action_cards, technical_details, |
| scene_json).""" |
| if image_path is None: |
| return ( |
| None, |
| "", |
| ('<div class="empty-state">π· <strong>Upload an image</strong> ' |
| "(or pick one from the sample gallery on the left) to generate " |
| "Passports.</div>"), |
| "", |
| "", |
| ) |
| |
| try: |
| safe_image_path = preprocess_image(image_path) |
| except ValueError as e: |
| return ( |
| None, "", |
| f'<div class="empty-state error">β οΈ <strong>Couldn\'t use this image</strong><br><br>{safe(str(e))}</div>', |
| "", "", |
| ) |
| except Exception as e: |
| return ( |
| None, "", |
| f'<div class="empty-state error">β οΈ <strong>Image couldn\'t be read</strong><br><br>' |
| f"This usually means the file is corrupted or in an unsupported format. " |
| f"<code>{safe(e.__class__.__name__)}</code></div>", |
| "", "", |
| ) |
|
|
| try: |
| engine = get_engine() |
| capture = CaptureInput( |
| image_path=Path(safe_image_path), |
| jurisdiction=jurisdiction.strip() or None, |
| ) |
| passports, scene_trace = engine.infer_scene_with_trace(capture, head) |
|
|
| annotated = render_bbox_overlay(safe_image_path, passports) if passports else Image.open(safe_image_path) |
| passports_json = [p.to_dict() for p in passports] |
| return ( |
| annotated, |
| render_kpi_strip(passports, scene_trace), |
| render_action_cards(passports, scene_trace), |
| render_technical_details(passports, scene_trace), |
| json.dumps(passports_json, indent=2), |
| ) |
| except MIEError as e: |
| msg = str(e) |
| if "no JSON" in msg or "invalid JSON" in msg: |
| user_msg = ( |
| "Gemma's output couldn't be parsed cleanly. This usually means the scene " |
| "is busy with many small items and the response got cut off mid-stream, " |
| "or the photo doesn't contain any items that fit the chosen domain.<br><br>" |
| "<strong>Try:</strong> a cleaner photo with fewer items, " |
| "a different material domain, or upload a closer crop of one specific object." |
| ) |
| elif "not in" in msg and "taxonomy" in msg: |
| user_msg = ( |
| "The model identified an item but it's not in the chosen domain's taxonomy.<br><br>" |
| "<strong>Try:</strong> pick a different material domain on the left " |
| "(e.g. <em>medical</em> for healthcare items, <em>ev</em> for batteries, " |
| "<em>cd</em> for construction debris)." |
| ) |
| else: |
| user_msg = ( |
| f"The pipeline couldn't process this image: <code>{msg[:160]}</code>.<br><br>" |
| "<strong>Try:</strong> a different photo, or a different domain." |
| ) |
| return ( |
| None, |
| "", |
| f'<div class="empty-state error">β οΈ <strong>Couldn\'t generate a Passport</strong><br><br>{user_msg}</div>', |
| f"<details><summary>Debug β full error</summary>\n\n```\n{msg}\n```\n</details>", |
| "", |
| ) |
| except Exception as e: |
| return ( |
| None, |
| "", |
| (f'<div class="empty-state error">β <strong>Runtime error:</strong> ' |
| f"<code>{e.__class__.__name__}: {e}</code><br><br>" |
| "If this is the first call after a cold start, the GPU worker is still " |
| "loading Gemma 4 (β30 s). Try again in a moment.</div>"), |
| f"<details><summary>traceback</summary>\n\n```\n{traceback.format_exc()}\n```\n</details>", |
| "", |
| ) |
|
|
|
|
| CSS = """ |
| @import url('https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,600&family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap'); |
| |
| /* ============================================================ |
| Gradio CSS variable overrides β single source of truth. |
| Every component (input, button, code, dropdown, examples, |
| accordion) reads from these, so we don't fight selectors. |
| ============================================================ */ |
| :root, .gradio-container, body, gradio-app { |
| /* Palette */ |
| --emerald: #00d97e; |
| --emerald-glow: #00ff8c; |
| --cyan: #00e5ff; |
| --leaf: #7dd3a8; |
| --ink: #f1faf4; |
| --ink-dim: #c4d8cd; |
| --ink-muted: #8aa79b; |
| |
| /* Body */ |
| --body-background-fill: transparent; |
| --body-text-color: #f1faf4; |
| --body-text-color-subdued: #c4d8cd; |
| --body-text-size: 14px; |
| --background-fill-primary: rgba(10, 28, 22, 0.62); |
| --background-fill-secondary: rgba(7, 18, 15, 0.55); |
| --border-color-primary: rgba(125, 211, 168, 0.20); |
| --border-color-accent: #00d97e; |
| --border-color-accent-subdued: rgba(0, 217, 126, 0.35); |
| |
| /* Accent */ |
| --color-accent: #00d97e; |
| --color-accent-soft: rgba(0, 217, 126, 0.18); |
| --link-text-color: #7dd3a8; |
| --link-text-color-active: #00ff8c; |
| --link-text-color-hover: #00ff8c; |
| --link-text-color-visited: #7dd3a8; |
| |
| /* Block (panel) */ |
| --block-background-fill: rgba(10, 28, 22, 0.62); |
| --block-border-color: rgba(125, 211, 168, 0.20); |
| --block-border-width: 1px; |
| --block-radius: 14px; |
| --block-padding: 18px; |
| --block-shadow: 0 4px 24px rgba(0, 0, 0, 0.18); |
| --block-label-background-fill: transparent; |
| --block-label-text-color: #c4d8cd; |
| --block-label-text-size: 0.78rem; |
| --block-label-text-weight: 600; |
| --block-title-background-fill: transparent; |
| --block-title-text-color: #f1faf4; |
| --block-title-text-size: 1rem; |
| --block-title-text-weight: 600; |
| --block-info-text-color: #c4d8cd; |
| --block-info-text-size: 0.82rem; |
| --block-info-text-weight: 400; |
| |
| /* Inputs */ |
| --input-background-fill: rgba(4, 12, 9, 0.7); |
| --input-background-fill-focus: rgba(4, 12, 9, 0.85); |
| --input-background-fill-hover: rgba(7, 18, 15, 0.85); |
| --input-border-color: rgba(125, 211, 168, 0.22); |
| --input-border-color-focus: #00d97e; |
| --input-border-color-hover: rgba(125, 211, 168, 0.34); |
| --input-text-size: 0.92rem; |
| --input-text-weight: 400; |
| --input-padding: 10px 14px; |
| --input-radius: 10px; |
| --input-shadow: none; |
| --input-shadow-focus: 0 0 0 3px rgba(0, 217, 126, 0.18); |
| --input-placeholder-color: #8aa79b; |
| |
| /* Buttons */ |
| --button-primary-background-fill: linear-gradient(135deg, #00d97e 0%, #00e5ff 100%); |
| --button-primary-background-fill-hover: linear-gradient(135deg, #00ff8c 0%, #00e5ff 100%); |
| --button-primary-text-color: #04130c; |
| --button-primary-text-color-hover: #04130c; |
| --button-primary-border-color: transparent; |
| --button-primary-border-color-hover: transparent; |
| --button-primary-shadow: 0 6px 24px rgba(0, 217, 126, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.25); |
| --button-primary-shadow-hover: 0 12px 32px rgba(0, 217, 126, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.3); |
| --button-secondary-background-fill: rgba(125, 211, 168, 0.06); |
| --button-secondary-background-fill-hover: rgba(0, 217, 126, 0.12); |
| --button-secondary-text-color: #f1faf4; |
| --button-secondary-text-color-hover: #f1faf4; |
| --button-secondary-border-color: rgba(125, 211, 168, 0.34); |
| --button-secondary-border-color-hover: #00d97e; |
| --button-secondary-shadow: none; |
| --button-large-padding: 14px 26px; |
| --button-large-radius: 999px; |
| --button-large-text-size: 0.95rem; |
| --button-large-text-weight: 600; |
| --button-small-padding: 8px 14px; |
| --button-small-radius: 999px; |
| --button-small-text-size: 0.85rem; |
| --button-small-text-weight: 500; |
| --button-transition: all 220ms cubic-bezier(0.2, 0.8, 0.2, 1); |
| |
| /* Checkboxes / radios */ |
| --checkbox-background-color: rgba(4, 12, 9, 0.7); |
| --checkbox-background-color-selected: #00d97e; |
| --checkbox-border-color: rgba(125, 211, 168, 0.34); |
| --checkbox-border-color-selected: #00d97e; |
| --checkbox-border-color-focus: #00d97e; |
| --checkbox-border-color-hover: #00d97e; |
| --checkbox-label-background-fill: rgba(4, 12, 9, 0.55); |
| --checkbox-label-background-fill-selected: rgba(0, 217, 126, 0.16); |
| --checkbox-label-background-fill-hover: rgba(0, 217, 126, 0.06); |
| --checkbox-label-border-color: rgba(125, 211, 168, 0.22); |
| --checkbox-label-border-color-selected: #00d97e; |
| --checkbox-label-text-color: #f1faf4; |
| --checkbox-label-text-color-selected: #00ff8c; |
| --checkbox-label-padding: 9px 14px; |
| |
| /* Slider */ |
| --slider-color: #00d97e; |
| |
| /* Code */ |
| --code-background-fill: rgba(2, 8, 6, 0.92); |
| --code-text-color: #d2efe0; |
| |
| /* Tabs */ |
| --tab-text-color-selected: #00ff8c; |
| |
| /* Radii */ |
| --radius-xxs: 6px; |
| --radius-xs: 8px; |
| --radius-sm: 10px; |
| --radius-md: 12px; |
| --radius-lg: 14px; |
| --radius-xl: 18px; |
| --layout-gap: 16px; |
| |
| /* Neutral scale */ |
| --neutral-50: #f1faf4; |
| --neutral-100: #d8e8df; |
| --neutral-200: #c4d8cd; |
| --neutral-300: #a4c0b3; |
| --neutral-400: #8aa79b; |
| --neutral-500: #6e8c80; |
| --neutral-600: #517065; |
| --neutral-700: #3a544b; |
| --neutral-800: #243832; |
| --neutral-900: #142420; |
| --neutral-950: #04080a; |
| |
| /* Primary scale */ |
| --primary-50: #d8fde9; |
| --primary-100: #aef9d2; |
| --primary-200: #80f1b8; |
| --primary-300: #4eea9a; |
| --primary-400: #1ee37c; |
| --primary-500: #00d97e; |
| --primary-600: #00b265; |
| --primary-700: #008c4f; |
| --primary-800: #006639; |
| --primary-900: #003f24; |
| --primary-950: #00210f; |
| } |
| |
| /* ===== Page background β kept as a fixed layer behind everything ===== */ |
| html, body, gradio-app, .gradio-container { |
| background: |
| radial-gradient(ellipse 80% 60% at 20% 0%, rgba(0, 217, 126, 0.18), transparent 60%), |
| radial-gradient(ellipse 70% 50% at 85% 20%, rgba(0, 229, 255, 0.10), transparent 60%), |
| radial-gradient(ellipse 90% 70% at 50% 110%, rgba(0, 217, 126, 0.10), transparent 60%), |
| linear-gradient(180deg, #04080a 0%, #061410 50%, #04080a 100%) !important; |
| color: #f1faf4 !important; |
| font-family: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif !important; |
| min-height: 100vh; |
| } |
| .gradio-container { max-width: 1280px !important; margin: 0 auto !important; padding: 0 24px !important; } |
| |
| /* ===== Hero ===== */ |
| #hero { padding: 36px 4px 16px; } |
| #hero h1 { |
| font-family: "Fraunces", Georgia, serif; |
| font-weight: 400; |
| font-size: clamp(2rem, 5vw, 3.4rem); |
| letter-spacing: -0.025em; |
| line-height: 1; margin: 0; color: #f1faf4; |
| } |
| #hero h1 em { |
| font-style: italic; |
| background: linear-gradient(135deg, #00ff8c, #00e5ff); |
| -webkit-background-clip: text; background-clip: text; |
| -webkit-text-fill-color: transparent; |
| font-weight: 300; |
| } |
| #hero p { color: #c4d8cd; margin-top: 14px; max-width: 680px; line-height: 1.6; font-size: 1rem; } |
| #hero p strong { color: #f1faf4; } |
| #hero .chip { |
| display: inline-flex; gap: 8px; align-items: center; |
| padding: 6px 14px; border-radius: 999px; |
| border: 1px solid rgba(125, 211, 168, 0.34); |
| background: linear-gradient(135deg, rgba(0, 217, 126, 0.08), rgba(0, 229, 255, 0.04)); |
| color: #7dd3a8; font-size: 0.82rem; font-weight: 500; |
| } |
| .dot { width: 8px; height: 8px; border-radius: 50%; background: #00ff8c; |
| box-shadow: 0 0 12px #00ff8c; display: inline-block; |
| animation: matter-pulse 1.6s ease-in-out infinite; } |
| @keyframes matter-pulse { 0%,100% {opacity:1;} 50% {opacity:0.4;} } |
| |
| /* ===== Markdown content (Passport summary, pipeline, section titles) ===== */ |
| .gradio-container .prose, |
| .gradio-container .markdown, |
| .gradio-container [class*="md"] { |
| color: #f1faf4 !important; |
| } |
| .gradio-container .prose p, |
| .gradio-container .prose li, |
| .gradio-container .prose td, |
| .gradio-container .prose strong { color: #f1faf4 !important; } |
| .gradio-container .prose em { color: #7dd3a8 !important; font-style: italic; } |
| .gradio-container .prose h1, |
| .gradio-container .prose h2, |
| .gradio-container .prose h3, |
| .gradio-container .prose h4 { |
| font-family: "Fraunces", Georgia, serif !important; |
| font-weight: 400 !important; |
| letter-spacing: -0.015em !important; |
| color: #f1faf4 !important; |
| } |
| .gradio-container .prose h3 { font-size: 1.35rem !important; margin-top: 4px !important; } |
| .gradio-container .prose code { |
| background: rgba(0, 217, 126, 0.10) !important; |
| color: #7dd3a8 !important; |
| font-family: "JetBrains Mono", ui-monospace, monospace !important; |
| padding: 2px 7px !important; |
| border-radius: 6px !important; |
| font-size: 0.86em !important; |
| border: 1px solid rgba(125, 211, 168, 0.20); |
| } |
| .gradio-container .prose table { |
| border-collapse: separate !important; |
| border-spacing: 0 !important; |
| width: 100%; |
| margin: 14px 0 !important; |
| border: 1px solid rgba(125, 211, 168, 0.20) !important; |
| border-radius: 12px !important; |
| overflow: hidden; |
| } |
| .gradio-container .prose th, |
| .gradio-container .prose td { |
| padding: 11px 14px !important; |
| border-bottom: 1px solid rgba(125, 211, 168, 0.14) !important; |
| text-align: left !important; |
| background: rgba(7, 18, 15, 0.45) !important; |
| color: #f1faf4 !important; |
| } |
| .gradio-container .prose tr:last-child td { border-bottom: 0 !important; } |
| .gradio-container .prose th { |
| background: rgba(0, 217, 126, 0.10) !important; |
| color: #7dd3a8 !important; |
| font-weight: 600 !important; |
| text-transform: uppercase; |
| font-size: 0.72rem; |
| letter-spacing: 0.08em; |
| } |
| |
| /* Section titles ("### Capture", "### Passport") get a gradient leader */ |
| .gradio-container .prose > h3:first-child { |
| display: flex; align-items: center; gap: 12px; |
| margin-bottom: 10px !important; |
| font-size: 1.5rem !important; |
| } |
| .gradio-container .prose > h3:first-child::before { |
| content: ""; width: 32px; height: 2px; |
| background: linear-gradient(90deg, #00d97e, transparent); |
| border-radius: 2px; |
| } |
| |
| /* ===== Image upload zone β reinforce dashed border + readable text ===== */ |
| .gradio-container [data-testid="image"], |
| .gradio-container [class*="image"] [class*="upload"], |
| .gradio-container [class*="ImageUploader"] { |
| background: rgba(4, 12, 9, 0.55) !important; |
| border: 1.5px dashed rgba(125, 211, 168, 0.40) !important; |
| border-radius: 14px !important; |
| color: #c4d8cd !important; |
| transition: all 220ms; |
| } |
| .gradio-container [class*="image"] [class*="upload"]:hover { |
| border-color: #00d97e !important; |
| background: rgba(0, 217, 126, 0.05) !important; |
| } |
| .gradio-container [class*="image"] [class*="upload"] *, |
| .gradio-container [class*="upload-text"] { |
| color: #c4d8cd !important; |
| } |
| .gradio-container [class*="image"] svg { color: #7dd3a8 !important; } |
| |
| /* ===== Examples gallery ===== */ |
| .gradio-container [class*="examples"] { |
| background: transparent !important; |
| } |
| .gradio-container [class*="examples"] table { |
| border-collapse: separate !important; |
| border-spacing: 8px !important; |
| border: 0 !important; |
| } |
| .gradio-container [class*="examples"] tr, |
| .gradio-container [class*="examples"] td { |
| background: rgba(7, 18, 15, 0.55) !important; |
| border: 1px solid rgba(125, 211, 168, 0.18) !important; |
| border-radius: 10px !important; |
| color: #f1faf4 !important; |
| transition: all 200ms; |
| } |
| .gradio-container [class*="examples"] tr:hover { |
| border-color: #00d97e !important; |
| background: rgba(0, 217, 126, 0.06) !important; |
| transform: translateY(-1px); |
| } |
| .gradio-container [class*="examples"] th { |
| color: #7dd3a8 !important; |
| font-size: 0.72rem; |
| text-transform: uppercase; |
| letter-spacing: 0.08em; |
| } |
| |
| /* ===== Accordion (Passport JSON drawer) ===== */ |
| .gradio-container details summary, |
| .gradio-container [class*="accordion"] [class*="title"] { |
| color: #7dd3a8 !important; |
| font-weight: 600 !important; |
| letter-spacing: 0.06em; |
| text-transform: uppercase; |
| font-size: 0.78rem !important; |
| } |
| |
| /* ===== Code block (JSON pane) ===== */ |
| .gradio-container [class*="code"] pre, |
| .gradio-container [class*="code"] code, |
| .gradio-container [class*="Code"] pre { |
| background: rgba(2, 8, 6, 0.92) !important; |
| color: #d2efe0 !important; |
| font-family: "JetBrains Mono", ui-monospace, monospace !important; |
| font-size: 0.8rem !important; |
| line-height: 1.65 !important; |
| border-radius: 12px !important; |
| border: 1px solid rgba(125, 211, 168, 0.18) !important; |
| padding: 16px !important; |
| } |
| .gradio-container [class*="code"] .token.string { color: #a5e8ff !important; } |
| .gradio-container [class*="code"] .token.property, |
| .gradio-container [class*="code"] .token.key { color: #7dd3a8 !important; } |
| .gradio-container [class*="code"] .token.number { color: #ffb547 !important; } |
| .gradio-container [class*="code"] .token.boolean, |
| .gradio-container [class*="code"] .token.null { color: #ff9bcc !important; } |
| .gradio-container [class*="code"] .token.punctuation { color: #8aa79b !important; } |
| |
| /* ===== Scrollbars ===== */ |
| .gradio-container ::-webkit-scrollbar { width: 8px; height: 8px; } |
| .gradio-container ::-webkit-scrollbar-thumb { background: rgba(125, 211, 168, 0.22); border-radius: 4px; } |
| .gradio-container ::-webkit-scrollbar-track { background: transparent; } |
| |
| /* ===== Selection ===== */ |
| ::selection { background: rgba(0, 217, 126, 0.35); color: white; } |
| |
| /* ===== KPI strip ===== */ |
| .kpi-strip { |
| display: grid; |
| grid-template-columns: repeat(4, 1fr); |
| gap: 12px; |
| margin: 22px 0 28px; |
| } |
| @media (max-width: 720px) { |
| .kpi-strip { grid-template-columns: repeat(2, 1fr); } |
| } |
| .kpi-card { |
| background: linear-gradient(180deg, rgba(10, 28, 22, 0.72), rgba(7, 18, 15, 0.50)); |
| border: 1px solid rgba(125, 211, 168, 0.18); |
| border-radius: 14px; |
| padding: 18px 16px; |
| text-align: center; |
| transition: border-color 240ms cubic-bezier(0.2,0.8,0.2,1), transform 240ms cubic-bezier(0.2,0.8,0.2,1); |
| } |
| .kpi-card:hover { |
| border-color: rgba(125, 211, 168, 0.36); |
| transform: translateY(-2px); |
| } |
| .kpi-card-alert { |
| background: linear-gradient(180deg, rgba(255, 107, 107, 0.10), rgba(7, 18, 15, 0.55)); |
| border-color: rgba(255, 107, 107, 0.40); |
| } |
| .kpi-emoji { |
| font-size: 1.7rem; |
| margin-bottom: 6px; |
| line-height: 1; |
| } |
| .kpi-num { |
| font-family: "Fraunces", Georgia, serif; |
| font-size: 2rem; |
| font-weight: 400; |
| letter-spacing: -0.02em; |
| color: #f1faf4; |
| line-height: 1; |
| } |
| .kpi-num-small { |
| font-family: "Inter", sans-serif !important; |
| font-size: 0.92rem !important; |
| font-weight: 500; |
| letter-spacing: 0; |
| color: #c4d8cd !important; |
| line-height: 1.3; |
| } |
| .kpi-unit { |
| font-size: 0.72rem; |
| color: #7dd3a8; |
| margin-left: 4px; |
| font-family: "Inter", sans-serif; |
| font-weight: 500; |
| letter-spacing: 0.04em; |
| } |
| .kpi-label { |
| font-size: 0.72rem; |
| letter-spacing: 0.10em; |
| text-transform: uppercase; |
| color: #c4d8cd; |
| margin-top: 8px; |
| font-weight: 600; |
| } |
| |
| /* ===== Section heading ===== */ |
| .section-heading { |
| font-family: "Fraunces", Georgia, serif; |
| font-weight: 400; |
| font-size: 1.6rem; |
| letter-spacing: -0.02em; |
| color: #f1faf4; |
| margin: 16px 0 8px; |
| display: flex; align-items: center; gap: 12px; |
| } |
| .section-heading::before { |
| content: ""; width: 28px; height: 1px; |
| background: linear-gradient(90deg, #00d97e, transparent); |
| } |
| |
| /* ===== Action cards ===== */ |
| .action-card { |
| background: linear-gradient(180deg, rgba(10, 28, 22, 0.62), rgba(7, 18, 15, 0.42)); |
| border: 1px solid rgba(125, 211, 168, 0.22); |
| border-radius: 16px; |
| padding: 22px 24px; |
| margin: 14px 0; |
| transition: border-color 280ms ease, transform 280ms cubic-bezier(0.2,0.8,0.2,1); |
| } |
| .action-card:hover { |
| border-color: rgba(125, 211, 168, 0.42); |
| transform: translateY(-1px); |
| } |
| .action-card-hazard { |
| background: linear-gradient(180deg, rgba(255, 107, 107, 0.06), rgba(7, 18, 15, 0.50)); |
| border-color: rgba(255, 107, 107, 0.42); |
| box-shadow: 0 0 28px rgba(255, 107, 107, 0.05); |
| } |
| .action-card-hazard:hover { |
| border-color: rgba(255, 107, 107, 0.65); |
| } |
| .card-header { |
| display: flex; align-items: center; justify-content: space-between; |
| gap: 16px; margin-bottom: 14px; flex-wrap: wrap; |
| } |
| .card-title { display: flex; align-items: center; gap: 12px; } |
| .card-num { |
| display: inline-flex; align-items: center; justify-content: center; |
| width: 26px; height: 26px; |
| border-radius: 50%; |
| background: rgba(125, 211, 168, 0.14); |
| color: #c4d8cd; |
| font-size: 0.78rem; font-weight: 700; |
| font-family: "JetBrains Mono", ui-monospace, monospace; |
| } |
| .card-emoji { font-size: 1.6rem; line-height: 1; } |
| .card-name { |
| font-family: "Fraunces", Georgia, serif; |
| font-weight: 400; |
| font-size: 1.34rem; |
| letter-spacing: -0.015em; |
| color: #f1faf4; |
| } |
| .card-badge { |
| display: inline-flex; align-items: center; |
| padding: 6px 13px; |
| border-radius: 999px; |
| font-size: 0.72rem; |
| letter-spacing: 0.10em; |
| text-transform: uppercase; |
| font-weight: 700; |
| white-space: nowrap; |
| } |
| .badge-hazard { |
| background: linear-gradient(135deg, rgba(255, 107, 107, 0.32), rgba(255, 107, 107, 0.08)); |
| border: 1px solid rgba(255, 107, 107, 0.55); |
| color: #ff9999; |
| } |
| .card-body { display: flex; flex-direction: column; gap: 10px; } |
| .card-action { |
| font-size: 1.06rem; |
| font-weight: 500; |
| color: #f1faf4; |
| letter-spacing: -0.005em; |
| } |
| .hazard-row { font-size: 1.0rem; line-height: 1.5; } |
| .hazard-row strong { |
| display: inline-block; |
| min-width: 84px; |
| font-family: "JetBrains Mono", ui-monospace, monospace; |
| font-size: 0.78rem; |
| font-weight: 700; |
| letter-spacing: 0.08em; |
| color: #c4d8cd; |
| margin-right: 10px; |
| } |
| .hazard-do-not { color: #ff9999; } |
| .hazard-do { color: #00ff8c; } |
| .card-reason { |
| font-size: 0.92rem; |
| color: #c4d8cd; |
| line-height: 1.55; |
| font-style: italic; |
| border-left: 2px solid rgba(125, 211, 168, 0.18); |
| padding-left: 12px; |
| } |
| .card-meta { |
| display: flex; align-items: center; |
| gap: 18px; margin-top: 6px; |
| flex-wrap: wrap; |
| } |
| .card-confidence { |
| display: flex; align-items: center; gap: 10px; |
| flex: 1; min-width: 180px; |
| } |
| .conf-label { |
| font-size: 0.70rem; |
| color: #c4d8cd; |
| letter-spacing: 0.10em; |
| text-transform: uppercase; |
| font-weight: 600; |
| white-space: nowrap; |
| } |
| .conf-bar { |
| flex: 1; |
| height: 6px; |
| border-radius: 3px; |
| background: rgba(125, 211, 168, 0.14); |
| overflow: hidden; |
| min-width: 80px; |
| } |
| .conf-fill { |
| height: 100%; |
| border-radius: 3px; |
| background: linear-gradient(90deg, #00d97e, #00e5ff); |
| } |
| .conf-pct { |
| font-family: "JetBrains Mono", ui-monospace, monospace; |
| font-size: 0.84rem; |
| font-weight: 500; |
| color: #f1faf4; |
| min-width: 36px; text-align: right; |
| } |
| .card-co2 { |
| display: inline-flex; align-items: center; gap: 4px; |
| font-family: "JetBrains Mono", ui-monospace, monospace; |
| font-size: 0.84rem; |
| color: #7dd3a8; |
| background: rgba(0, 217, 126, 0.08); |
| padding: 4px 11px; |
| border-radius: 999px; |
| border: 1px solid rgba(0, 217, 126, 0.22); |
| } |
| |
| /* ===== Domain pill (per-detection) + auto-route note ===== */ |
| .domain-pill { |
| display: inline-flex; |
| align-items: center; |
| padding: 3px 10px; |
| margin-left: 6px; |
| border-radius: 999px; |
| font-size: 0.66rem; |
| letter-spacing: 0.10em; |
| text-transform: uppercase; |
| font-weight: 700; |
| font-family: "JetBrains Mono", ui-monospace, monospace; |
| color: #c4d8cd; |
| background: rgba(125, 211, 168, 0.10); |
| border: 1px solid rgba(125, 211, 168, 0.28); |
| white-space: nowrap; |
| } |
| .auto-route-note { |
| margin: 8px 0 14px; |
| padding: 12px 14px; |
| border-radius: 12px; |
| background: linear-gradient(135deg, rgba(0, 217, 126, 0.06), rgba(0, 229, 255, 0.03)); |
| border: 1px solid rgba(125, 211, 168, 0.24); |
| color: #c4d8cd; |
| font-size: 0.86rem; |
| line-height: 1.45; |
| } |
| .auto-route-note strong { color: #f1faf4; } |
| |
| /* ===== Empty state ===== */ |
| .empty-state { |
| text-align: center; |
| padding: 36px 20px; |
| border: 1px dashed rgba(125, 211, 168, 0.30); |
| border-radius: 14px; |
| background: rgba(7, 18, 15, 0.45); |
| color: #c4d8cd; |
| font-size: 0.96rem; |
| line-height: 1.55; |
| } |
| .empty-state.error { |
| border-color: rgba(255, 107, 107, 0.45); |
| background: rgba(255, 107, 107, 0.04); |
| color: #ffcccc; |
| } |
| .empty-state code { |
| background: rgba(255, 107, 107, 0.10) !important; |
| color: #ff9999 !important; |
| } |
| |
| /* ===== Catch-all readability ===== */ |
| .gradio-container { color: #f1faf4; } |
| .gradio-container input::placeholder, |
| .gradio-container textarea::placeholder { color: #8aa79b !important; opacity: 1 !important; } |
| """ |
|
|
| HERO_HTML = """ |
| <style>__MATTER_CSS__</style> |
| <div id="hero"> |
| <span class="chip"><span class="dot"></span> Powered by Gemma 4 Β· On-device Β· CC0 Passport</span> |
| <h1 style="margin-top:18px;">Material in. <em>Passport out.</em></h1> |
| <p>Point a camera at a thing β bottle, battery, syringe, denim, concrete, e-waste β and Matter |
| returns a calibrated, hazard-aware <strong style="color:var(--ink)">Passport</strong> that routes |
| it to its right next life. One vocabulary, six material heads, four post-model layers, validated.</p> |
| </div> |
| """ |
|
|
|
|
| def build_examples() -> list[list]: |
| rows = [] |
| for head, fname in SAMPLE_IMAGES.items(): |
| p = EXAMPLES_DIR / fname |
| if p.exists(): |
| rows.append([str(p), head, ""]) |
| return rows |
|
|
|
|
| with gr.Blocks(title="Matter β Material Intelligence") as demo: |
| gr.HTML(HERO_HTML.replace("__MATTER_CSS__", CSS)) |
|
|
| with gr.Row(): |
| with gr.Column(scale=5): |
| gr.Markdown("### Capture") |
| image_in = gr.Image( |
| label="Upload an image", |
| type="filepath", |
| height=320, |
| sources=["upload", "webcam", "clipboard"], |
| ) |
| head_in = gr.Dropdown( |
| label="Material domain", |
| choices=HEAD_NAMES, |
| value="domestic", |
| info="Pick the domain that matches your photo. Each domain has its own taxonomy and safety rules.", |
| ) |
| juris_in = gr.Textbox( |
| label="Jurisdiction (optional)", |
| placeholder="leave blank to use the domain's default", |
| value="", |
| ) |
| run_btn = gr.Button("Generate Passports", variant="primary", size="lg") |
|
|
| ex = build_examples() |
| if ex: |
| gr.Examples( |
| examples=ex, |
| inputs=[image_in, head_in, juris_in], |
| label="Sample materials", |
| examples_per_page=6, |
| ) |
|
|
| with gr.Column(scale=7): |
| |
| annotated_out = gr.Image( |
| label="Detected objects", |
| type="pil", |
| height=440, |
| interactive=False, |
| ) |
|
|
| |
| kpi_out = gr.Markdown(value="", elem_id="kpi-strip-host") |
|
|
| |
| gr.Markdown( |
| "<div class=\"section-heading\">What to do with each item</div>", |
| elem_id="action-section-heading", |
| ) |
| cards_out = gr.Markdown( |
| value=('<div class="empty-state">π· <strong>Upload an image</strong> ' |
| "(or pick one from the sample gallery on the left) to generate " |
| "Passports.</div>"), |
| elem_id="action-cards-host", |
| ) |
|
|
| |
| with gr.Accordion("π¬ Technical details (pipeline trace + verifier + raw model output)", |
| open=False): |
| technical_out = gr.Markdown() |
| with gr.Accordion("π Passport JSON (full list)", open=False): |
| json_out = gr.Code(language="json", label=None, lines=20) |
|
|
| gr.Markdown( |
| "<div style='color:var(--ink-dim);font-size:0.85rem;margin-top:32px;text-align:center;'>" |
| "Matter Β· open Material Intelligence platform Β· " |
| "Built for the <strong>Gemma 4 Impact Challenge</strong>" |
| "</div>" |
| ) |
|
|
| run_btn.click( |
| run, |
| inputs=[image_in, head_in, juris_in], |
| outputs=[annotated_out, kpi_out, cards_out, technical_out, json_out], |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| demo.queue(max_size=8).launch(server_name="0.0.0.0", show_error=True, ssr_mode=False) |
|
|