"""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 color palette — one stable color per class for visual consistency. # Uses our theme accents (emerald, cyan, amber, rose, leaf, lavender). 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()) # domestic, ewaste, ev, medical, cd, textile 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) # ===================================================================== # Bbox overlay rendering # ===================================================================== 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 to load a clean font; fall back to PIL default if unavailable 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) # Sort the corners in case Gemma returned them out of order 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) # Box outline (thicker, semi-transparent fill) draw.rectangle(px, outline=color, width=3) fill_rgba = _hex_to_rgba(color, alpha=24) draw.rectangle(px, fill=fill_rgba) # Label: "1 · plastic_bottle 0.91" label = f"{i} · {cls} {p.identity.confidence:.2f}" # Text box for legibility 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) # ===================================================================== # Input safety — image preprocessing + HTML escaping # ===================================================================== # Hard cap on image dimensions before we ever hand bytes to PIL or Gemma. # 2048×2048 is plenty for vision (Gemma resizes to ~896 internally) and # bounds memory + GPU runtime. iPhone shots come in at ~4032×3024, so most # real uploads will be downscaled. MAX_IMAGE_DIM = 2048 MIN_IMAGE_DIM = 64 # below this Gemma can't see anything 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 `` 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) # ===================================================================== # Rendering helpers — every section returns a Markdown string. # ===================================================================== # ===================================================================== # Product-design renderers — what a non-technical user sees first. # ===================================================================== # Class identity → (emoji, friendly display name). # Falls through gracefully for any class not listed. CLASS_LOOKS: dict[str, tuple[str, str]] = { # domestic "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"), # ewaste "laptop": ("💻", "Laptop"), "smartphone": ("📱", "Smartphone"), "cable": ("🔌", "Cable"), "power_adapter": ("🔌", "Power adapter"), "audio": ("🎧", "Audio device"), "battery": ("🔋", "Battery"), "pcb": ("🔧", "Circuit board"), "lighting": ("💡", "Lighting"), # ev "lithium_ion_cell": ("🔋", "Li-ion cell"), "lead_acid_battery": ("🔋", "Lead-acid battery"), "battery_pack": ("🔋", "Battery pack"), "connector": ("🔌", "Connector"), # medical "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"), # cd "concrete": ("🧱", "Concrete"), "drywall": ("🧱", "Drywall"), "wood": ("🪵", "Wood"), "rebar": ("🔩", "Rebar"), "tile": ("🟫", "Tile"), "insulation": ("❄️", "Insulation"), "pvc": ("🚰", "PVC pipe"), # textile "denim": ("👖", "Denim"), "cotton": ("👕", "Cotton textile"), "polyester": ("🧥", "Synthetic textile"), "wool": ("🧶", "Wool"), } # Action ID → (verb sentence, badge label, accent color). 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 "—" # 4th tile: in universal mode, surface the heads detected; otherwise the # legacy single-head jurisdiction. 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 ( '
' + f'
📦
' f'
{n}
' f'
{"item" if n == 1 else "items"} processed
' + f'
🌱
' f'
{total_co2:.3f}kg
' f'
CO₂e avoided
' + f'
{hazard_emoji}
' f'
{hazards_caught}
' f'
{"hazard caught" if hazards_caught == 1 else "hazards caught"}
' + f'
{tile_emoji}
' f'
{safe(tile_value)}
' f'
{safe(tile_label)}
' + '
' ) 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 ( '
' "🔍 No recognizable items detected.
" "Try a clearer image, or pick a different material domain on the left." "
" ) 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 "") # Guardrail accent colors are constants we control; render via the `safe` # template values where the source is the model. 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: # Truncate to 200 chars defensively — long-form prompt-injection # payloads are commonly shorter than ~150 chars but the cap is good # belt-and-braces. 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() # Note: emoji and display_name come from our CLASS_LOOKS map (controlled). # bin_label and do_not_pretty come from ACTION_VERBS (controlled). # severity is from safety_rules_v1.json (our spec). # reason is the only model-emitted free-text — escaped via safe(). return ( f'
' f'
' f'
' f' {int(idx)}' f' {safe(emoji)}' f' {safe(display_name)}' + (f' {safe(domain_label)}' if domain_label else '') + f'
' f'
⚠️ Hazard · {safe(sev_label)}
' f'
' f'
' f'
DO NOT {safe(do_not_pretty)}
' f'
TAKE TO {safe(bin_label.lower())}
' + (f'
Why this matters: {safe(reason)}
' if reason else '') + f'
' f'
' f'
Confidence
' f'
' f'
{int(confidence_pct)}%
' f'
' + (f' {safe(co2_str)}' if co2_str else '') + f'
' f'
' f'
' ) return ( f'
' f'
' f'
' f' {int(idx)}' f' {safe(emoji)}' f' {safe(display_name)}' + (f' {safe(domain_label)}' if domain_label else '') + f'
' + f'
{safe(bin_label)}
' f'
' f'
' f'
→ {safe(verb)}
' + (f'
{safe(reason)}
' if reason else '') + f'
' f'
' f'
Confidence
' f'
' f'
{int(confidence_pct)}%
' f'
' + (f' {safe(co2_str)}' if co2_str else '') + f'
' f'
' f'
' ) 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("") # Verifier per-detection 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 Gemma output 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//v0.1`.""" if not uri: return None parts = uri.rstrip("/").split("/") return parts[-2] if len(parts) >= 2 else None # ===================================================================== # Run handlers # ===================================================================== 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, "", ('
📷 Upload an image ' "(or pick one from the sample gallery on the left) to generate " "Passports.
"), "", "", ) # Sanitize the uploaded image before any model or pipeline touches it. try: safe_image_path = preprocess_image(image_path) except ValueError as e: return ( None, "", f'
⚠️ Couldn\'t use this image

{safe(str(e))}
', "", "", ) except Exception as e: return ( None, "", f'
⚠️ Image couldn\'t be read

' f"This usually means the file is corrupted or in an unsupported format. " f"{safe(e.__class__.__name__)}
", "", "", ) 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.

" "Try: 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.

" "Try: pick a different material domain on the left " "(e.g. medical for healthcare items, ev for batteries, " "cd for construction debris)." ) else: user_msg = ( f"The pipeline couldn't process this image: {msg[:160]}.

" "Try: a different photo, or a different domain." ) return ( None, "", f'
⚠️ Couldn\'t generate a Passport

{user_msg}
', f"
Debug — full error\n\n```\n{msg}\n```\n
", "", ) except Exception as e: return ( None, "", (f'
Runtime error: ' f"{e.__class__.__name__}: {e}

" "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.
"), f"
traceback\n\n```\n{traceback.format_exc()}\n```\n
", "", ) 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 = """
Powered by Gemma 4 · On-device · CC0 Passport

Material in. Passport out.

Point a camera at a thing — bottle, battery, syringe, denim, concrete, e-waste — and Matter returns a calibrated, hazard-aware Passport that routes it to its right next life. One vocabulary, six material heads, four post-model layers, validated.

""" 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): # 1. The visual proof — annotated image with bbox overlays annotated_out = gr.Image( label="Detected objects", type="pil", height=440, interactive=False, ) # 2. KPI strip — items / CO2e / hazards / jurisdiction kpi_out = gr.Markdown(value="", elem_id="kpi-strip-host") # 3. Per-item action cards — the user-facing answer to "what do I do?" gr.Markdown( "
What to do with each item
", elem_id="action-section-heading", ) cards_out = gr.Markdown( value=('
📷 Upload an image ' "(or pick one from the sample gallery on the left) to generate " "Passports.
"), elem_id="action-cards-host", ) # 4. Technical details — collapsed by default, for engineers / judges 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( "
" "Matter · open Material Intelligence platform · " "Built for the Gemma 4 Impact Challenge" "
" ) 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)