"""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'
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'
'
)
return (
f''
f' '
f'
'
f'
→ {safe(verb)}
'
+ (f'
{safe(reason)}
' if reason 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)