SPP Claude Sonnet 4.6 commited on
Commit ·
98ceb88
1
Parent(s): 7836b23
Initial upload: Vision Base MiniCPM-V 4.6 demo suite
Browse filesFour apps (Allergen Lens, Fridge Dinner, Object Oracle, What's That Error)
switchable via APP_ID env var, running on ZeroGPU.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- .gitignore +5 -0
- app.py +223 -0
- core/__init__.py +0 -0
- core/apps.py +439 -0
- core/model.py +80 -0
- core/parse.py +41 -0
- examples/allergen_label.jpg +0 -0
- examples/allergen_label2.jpg +0 -0
- examples/concierge_event.jpg +0 -0
- examples/concierge_receipt.jpg +0 -0
- examples/concierge_screenshot.jpg +0 -0
- examples/error_screen.jpg +0 -0
- examples/error_screen2.jpg +0 -0
- examples/fridge_dinner.jpg +0 -0
- examples/oracle_object.jpg +0 -0
- requirements.txt +8 -0
- scripts/fetch_examples.py +73 -0
- scripts/generate_examples.py +387 -0
.gitignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
.venv/
|
| 5 |
+
.env
|
app.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 2 |
+
# Deploy each app by setting APP_ID before launching:
|
| 3 |
+
#
|
| 4 |
+
# APP_ID=allergen_lens python app.py # food label scanner
|
| 5 |
+
# APP_ID=fridge_dinner python app.py # fridge-to-dinner planner
|
| 6 |
+
# APP_ID=object_oracle python app.py # tarot-style reading
|
| 7 |
+
# APP_ID=whats_that_error python app.py # error code fixer
|
| 8 |
+
#
|
| 9 |
+
# On Hugging Face Spaces: set APP_ID in Space → Settings → Variables & Secrets.
|
| 10 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 11 |
+
|
| 12 |
+
import spaces # MUST be the first import for ZeroGPU
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
import gradio as gr
|
| 18 |
+
from PIL import Image
|
| 19 |
+
|
| 20 |
+
from core.apps import APP_REGISTRY, _error_html, _error_updates
|
| 21 |
+
from core.model import vision_infer
|
| 22 |
+
from core.parse import parse_json
|
| 23 |
+
|
| 24 |
+
# ── Config ────────────────────────────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
APP_ID = os.environ.get("APP_ID", "allergen_lens")
|
| 27 |
+
if APP_ID not in APP_REGISTRY:
|
| 28 |
+
raise ValueError(f"Unknown APP_ID '{APP_ID}'. Choose from: {list(APP_REGISTRY)}")
|
| 29 |
+
|
| 30 |
+
spec = APP_REGISTRY[APP_ID]
|
| 31 |
+
|
| 32 |
+
# ── Image helpers ─────────────────────────────────────────────────────────────
|
| 33 |
+
|
| 34 |
+
def _pil(item) -> "Image.Image | None":
|
| 35 |
+
if item is None:
|
| 36 |
+
return None
|
| 37 |
+
if isinstance(item, Image.Image):
|
| 38 |
+
return item.convert("RGB")
|
| 39 |
+
if isinstance(item, str) and Path(item).exists():
|
| 40 |
+
return Image.open(item).convert("RGB")
|
| 41 |
+
if isinstance(item, (list, tuple)) and len(item) >= 1:
|
| 42 |
+
return _pil(item[0])
|
| 43 |
+
if hasattr(item, "__array__"):
|
| 44 |
+
import numpy as np
|
| 45 |
+
arr = item if isinstance(item, np.ndarray) else item.__array__()
|
| 46 |
+
return Image.fromarray(arr).convert("RGB")
|
| 47 |
+
return None
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _collect_images(raw) -> list:
|
| 51 |
+
if raw is None:
|
| 52 |
+
return []
|
| 53 |
+
if not isinstance(raw, (list, tuple)):
|
| 54 |
+
img = _pil(raw)
|
| 55 |
+
return [img] if img else []
|
| 56 |
+
imgs = [_pil(item) for item in raw]
|
| 57 |
+
return [i for i in imgs if i is not None]
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ── Status constants ──────────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
_STATUS_WARM = "⏳ Warming up the model — first run takes a few seconds…"
|
| 63 |
+
_STATUS_CLEAR = ""
|
| 64 |
+
|
| 65 |
+
# ── Inference handler (generator — enables warm-up status) ────────────────────
|
| 66 |
+
|
| 67 |
+
def _run(image_input, *extra_inputs):
|
| 68 |
+
"""
|
| 69 |
+
Generator so we can yield the warm-up message before the GPU call blocks,
|
| 70 |
+
then yield the result (+ cleared status) when done.
|
| 71 |
+
Outputs: [status_md] + one gr.update per spec.output_components entry.
|
| 72 |
+
"""
|
| 73 |
+
n_out = len(spec.output_components)
|
| 74 |
+
|
| 75 |
+
# Phase 1: show warm-up status immediately; leave result components unchanged
|
| 76 |
+
yield [gr.update(value=_STATUS_WARM)] + [gr.update()] * n_out
|
| 77 |
+
|
| 78 |
+
images = _collect_images(image_input)
|
| 79 |
+
if not images:
|
| 80 |
+
yield [gr.update(value=_STATUS_CLEAR)] + _error_updates(
|
| 81 |
+
n_out, "Please upload at least one image."
|
| 82 |
+
)
|
| 83 |
+
return
|
| 84 |
+
|
| 85 |
+
instruction = spec.instruction_fn(*extra_inputs)
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
raw = vision_infer(
|
| 89 |
+
images=images,
|
| 90 |
+
instruction=instruction,
|
| 91 |
+
json_mode=(spec.output_mode == "json"),
|
| 92 |
+
max_tokens=spec.max_tokens,
|
| 93 |
+
)
|
| 94 |
+
except Exception as exc:
|
| 95 |
+
yield [gr.update(value=_STATUS_CLEAR)] + _error_updates(n_out, f"Inference error: {exc}")
|
| 96 |
+
return
|
| 97 |
+
|
| 98 |
+
data = parse_json(raw) if spec.output_mode == "json" else raw
|
| 99 |
+
updates = spec.render_fn(data)
|
| 100 |
+
|
| 101 |
+
# Phase 2: clear status, deliver result
|
| 102 |
+
yield [gr.update(value=_STATUS_CLEAR)] + updates
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ── UI builder (generic — driven entirely by AppSpec) ─────────────────────────
|
| 106 |
+
|
| 107 |
+
def _build_output_components(component_types: list[str]) -> list:
|
| 108 |
+
"""Create one Gradio component per declared output slot."""
|
| 109 |
+
comps = []
|
| 110 |
+
for kind in component_types:
|
| 111 |
+
if kind == "html":
|
| 112 |
+
comps.append(
|
| 113 |
+
gr.HTML(
|
| 114 |
+
value=(
|
| 115 |
+
'<div style="min-height:180px;display:flex;align-items:center;'
|
| 116 |
+
'justify-content:center;color:#aaa;font-family:system-ui;font-size:15px">'
|
| 117 |
+
'Upload an image and click Analyze ✦'
|
| 118 |
+
'</div>'
|
| 119 |
+
)
|
| 120 |
+
)
|
| 121 |
+
)
|
| 122 |
+
elif kind == "markdown":
|
| 123 |
+
comps.append(gr.Markdown(""))
|
| 124 |
+
else:
|
| 125 |
+
comps.append(gr.HTML(""))
|
| 126 |
+
return comps
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def build_demo(spec) -> gr.Blocks:
|
| 130 |
+
css = """
|
| 131 |
+
.submit-btn { font-size: 16px !important; padding: 12px 0 !important; }
|
| 132 |
+
footer { display: none !important; }
|
| 133 |
+
"""
|
| 134 |
+
|
| 135 |
+
with gr.Blocks(title=spec.title, css=css, theme=gr.themes.Soft()) as demo:
|
| 136 |
+
gr.HTML(
|
| 137 |
+
f'<div style="padding:16px 0 8px">'
|
| 138 |
+
f'<h1 style="margin:0;font-size:26px">{spec.title}</h1>'
|
| 139 |
+
f'<p style="margin:4px 0 0;color:#555;font-size:15px">{spec.tagline}</p>'
|
| 140 |
+
f'</div>'
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
# Shared status indicator — one per page, always present
|
| 144 |
+
status_md = gr.Markdown(_STATUS_CLEAR, visible=True)
|
| 145 |
+
|
| 146 |
+
with gr.Row(equal_height=False):
|
| 147 |
+
# ── Left column: inputs ───────────────────────────────────────────
|
| 148 |
+
with gr.Column(scale=1):
|
| 149 |
+
image_comp = gr.Image(
|
| 150 |
+
label="Upload image",
|
| 151 |
+
type="pil",
|
| 152 |
+
sources=["upload", "webcam", "clipboard"],
|
| 153 |
+
height=300,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
extra_comps = []
|
| 157 |
+
|
| 158 |
+
if spec.input_spec.text_label:
|
| 159 |
+
text_comp = gr.Textbox(
|
| 160 |
+
label=spec.input_spec.text_label,
|
| 161 |
+
placeholder=spec.input_spec.text_placeholder,
|
| 162 |
+
lines=2,
|
| 163 |
+
)
|
| 164 |
+
extra_comps.append(text_comp)
|
| 165 |
+
|
| 166 |
+
if spec.input_spec.dropdown_choices:
|
| 167 |
+
drop_comp = gr.Dropdown(
|
| 168 |
+
choices=spec.input_spec.dropdown_choices,
|
| 169 |
+
value=spec.input_spec.dropdown_default,
|
| 170 |
+
label=spec.input_spec.dropdown_label,
|
| 171 |
+
)
|
| 172 |
+
extra_comps.append(drop_comp)
|
| 173 |
+
|
| 174 |
+
submit = gr.Button("Analyze ✦", variant="primary", elem_classes=["submit-btn"])
|
| 175 |
+
|
| 176 |
+
# ── Right column: outputs (built from spec) ───────────────────────
|
| 177 |
+
with gr.Column(scale=1):
|
| 178 |
+
output_comps = _build_output_components(spec.output_components)
|
| 179 |
+
|
| 180 |
+
all_inputs = [image_comp] + extra_comps
|
| 181 |
+
all_outputs = [status_md] + output_comps
|
| 182 |
+
|
| 183 |
+
submit.click(fn=_run, inputs=all_inputs, outputs=all_outputs)
|
| 184 |
+
|
| 185 |
+
# Reset outputs when a new image is uploaded
|
| 186 |
+
def _on_image_change(*_):
|
| 187 |
+
placeholder = (
|
| 188 |
+
'<div style="min-height:180px;display:flex;align-items:center;'
|
| 189 |
+
'justify-content:center;color:#aaa;font-family:system-ui">'
|
| 190 |
+
'Click Analyze ✦ to process</div>'
|
| 191 |
+
)
|
| 192 |
+
return [gr.update(value=_STATUS_CLEAR)] + [
|
| 193 |
+
gr.update(value=placeholder) for _ in spec.output_components
|
| 194 |
+
]
|
| 195 |
+
|
| 196 |
+
image_comp.change(fn=_on_image_change, inputs=[image_comp], outputs=all_outputs)
|
| 197 |
+
|
| 198 |
+
# Examples (only when local files exist)
|
| 199 |
+
valid_examples = [
|
| 200 |
+
row for row in spec.examples
|
| 201 |
+
if row and Path(str(row[0])).exists()
|
| 202 |
+
]
|
| 203 |
+
if valid_examples:
|
| 204 |
+
gr.Examples(
|
| 205 |
+
examples=valid_examples,
|
| 206 |
+
inputs=all_inputs,
|
| 207 |
+
label="Try an example",
|
| 208 |
+
cache_examples=False,
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
gr.HTML(
|
| 212 |
+
'<div style="text-align:center;color:#aaa;font-size:12px;margin-top:16px">'
|
| 213 |
+
f'Powered by <b>MiniCPM-V 4.6 (1.3B)</b> · ZeroGPU · App: <code>{APP_ID}</code>'
|
| 214 |
+
'</div>'
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
return demo
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
demo = build_demo(spec)
|
| 221 |
+
|
| 222 |
+
if __name__ == "__main__":
|
| 223 |
+
demo.launch(show_error=True)
|
core/__init__.py
ADDED
|
File without changes
|
core/apps.py
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import gradio as gr
|
| 2 |
+
from dataclasses import dataclass, field
|
| 3 |
+
from typing import Callable, List, Optional
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class InputSpec:
|
| 8 |
+
multi_image: bool = False
|
| 9 |
+
text_label: Optional[str] = None
|
| 10 |
+
text_placeholder: str = ""
|
| 11 |
+
dropdown_choices: Optional[List[str]] = None
|
| 12 |
+
dropdown_label: str = ""
|
| 13 |
+
dropdown_default: str = ""
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass
|
| 17 |
+
class AppSpec:
|
| 18 |
+
app_id: str
|
| 19 |
+
title: str
|
| 20 |
+
tagline: str
|
| 21 |
+
# instruction_fn(*extra_inputs) -> str (extra_inputs = text + dropdown, if present)
|
| 22 |
+
instruction_fn: Callable
|
| 23 |
+
output_mode: str # "text" | "json"
|
| 24 |
+
input_spec: InputSpec
|
| 25 |
+
max_tokens: int # floor 512 (oracle) or 1024 (JSON apps)
|
| 26 |
+
examples: List[List] # Gradio examples rows; each row matches inputs order
|
| 27 |
+
# render_fn(result: str|dict|list) -> list[gr.update], one per output_components entry
|
| 28 |
+
render_fn: Callable
|
| 29 |
+
output_components: List[str] = field(default_factory=lambda: ["html"])
|
| 30 |
+
theme_color: str = "#5c6bc0"
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 34 |
+
# Shared render helpers
|
| 35 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 36 |
+
|
| 37 |
+
def _card(body: str, border_color: str = "#5c6bc0", bg: str = "#fafafa") -> str:
|
| 38 |
+
return (
|
| 39 |
+
f'<div style="border-left:5px solid {border_color};padding:20px 24px;'
|
| 40 |
+
f'background:{bg};border-radius:8px;font-family:system-ui,sans-serif">'
|
| 41 |
+
f"{body}</div>"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _error_html(msg: str) -> str:
|
| 46 |
+
return _card(
|
| 47 |
+
f"<p style='color:#c62828;margin:0'><b>Error:</b> {msg}</p>",
|
| 48 |
+
"#c62828",
|
| 49 |
+
"#fff8f8",
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _error_updates(n_components: int, msg: str) -> list:
|
| 54 |
+
"""n_components gr.update objects; error goes in slot 0 (always an html component)."""
|
| 55 |
+
updates = [gr.update(value=_error_html(msg))]
|
| 56 |
+
updates += [gr.update()] * (n_components - 1)
|
| 57 |
+
return updates
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 61 |
+
# 1. allergen_lens
|
| 62 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 63 |
+
|
| 64 |
+
_ALLERGEN_SCHEMA = (
|
| 65 |
+
'{"allergens": ["peanuts", "..."], '
|
| 66 |
+
'"dietary": {"vegetarian": true, "vegan": false, "gluten_free": false}, '
|
| 67 |
+
'"prep_instructions": "...", '
|
| 68 |
+
'"verdict": "Safe to eat" | "Contains allergens" | "Check carefully"}'
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
_ALLERGEN_LANGUAGES = [
|
| 72 |
+
"English", "Spanish", "French", "German", "Portuguese",
|
| 73 |
+
"Italian", "Japanese", "Korean", "Chinese (Simplified)", "Arabic",
|
| 74 |
+
]
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _allergen_instruction(allergies: str = "", language: str = "English") -> str:
|
| 78 |
+
prompt = (
|
| 79 |
+
"You are a food-safety assistant. Analyze this food product label image.\n"
|
| 80 |
+
"Extract ALL allergen information (common allergens: peanuts, tree nuts, milk, eggs, "
|
| 81 |
+
"wheat/gluten, soy, fish, shellfish, sesame).\n"
|
| 82 |
+
"Determine vegetarian / vegan / gluten-free status.\n"
|
| 83 |
+
"Extract preparation instructions if visible.\n"
|
| 84 |
+
)
|
| 85 |
+
if allergies.strip():
|
| 86 |
+
prompt += (
|
| 87 |
+
f"\nThe user has these dietary restrictions / allergies: {allergies.strip()}\n"
|
| 88 |
+
"Set 'verdict' to 'Contains allergens' if any of the user's allergens are present, "
|
| 89 |
+
"'Safe to eat' if none are present, or 'Check carefully' if unsure.\n"
|
| 90 |
+
)
|
| 91 |
+
else:
|
| 92 |
+
prompt += "\nSet 'verdict' to a general safety summary.\n"
|
| 93 |
+
prompt += f"\nRespond in {language}.\n"
|
| 94 |
+
prompt += f"\nOutput schema: {_ALLERGEN_SCHEMA}"
|
| 95 |
+
return prompt
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _allergen_render(data) -> list:
|
| 99 |
+
if isinstance(data, dict) and "error" in data:
|
| 100 |
+
return [gr.update(value=_error_html(data.get("raw", data["error"])))]
|
| 101 |
+
|
| 102 |
+
verdict = data.get("verdict", "Unknown")
|
| 103 |
+
allergens = data.get("allergens", [])
|
| 104 |
+
dietary = data.get("dietary", {})
|
| 105 |
+
prep = data.get("prep_instructions", "")
|
| 106 |
+
|
| 107 |
+
has_allergens = bool(allergens)
|
| 108 |
+
border = "#c62828" if has_allergens else "#2e7d32"
|
| 109 |
+
verdict_bg = "#ffebee" if has_allergens else "#e8f5e9"
|
| 110 |
+
verdict_color = "#c62828" if has_allergens else "#2e7d32"
|
| 111 |
+
|
| 112 |
+
chips = "".join(
|
| 113 |
+
f'<span style="display:inline-block;background:#ef5350;color:white;'
|
| 114 |
+
f'padding:3px 10px;border-radius:12px;margin:3px 3px 3px 0;font-size:13px">{a}</span>'
|
| 115 |
+
for a in allergens
|
| 116 |
+
)
|
| 117 |
+
if not chips:
|
| 118 |
+
chips = '<span style="color:#757575;font-style:italic">None detected</span>'
|
| 119 |
+
|
| 120 |
+
dietary_icons = []
|
| 121 |
+
if dietary.get("vegetarian"):
|
| 122 |
+
dietary_icons.append("🥬 Vegetarian")
|
| 123 |
+
if dietary.get("vegan"):
|
| 124 |
+
dietary_icons.append("🌱 Vegan")
|
| 125 |
+
if dietary.get("gluten_free"):
|
| 126 |
+
dietary_icons.append("✓ Gluten-Free")
|
| 127 |
+
dietary_text = " · ".join(dietary_icons) if dietary_icons else "No special designations"
|
| 128 |
+
|
| 129 |
+
prep_section = (
|
| 130 |
+
f'<div style="margin-top:14px">'
|
| 131 |
+
f'<b style="color:#555">Preparation:</b> '
|
| 132 |
+
f'<span style="color:#333">{prep}</span></div>'
|
| 133 |
+
if prep else ""
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
body = (
|
| 137 |
+
f'<div style="background:{verdict_bg};border-radius:6px;padding:12px 16px;margin-bottom:16px">'
|
| 138 |
+
f'<span style="font-size:22px;font-weight:700;color:{verdict_color}">{verdict}</span>'
|
| 139 |
+
f'</div>'
|
| 140 |
+
f'<div style="margin-bottom:12px"><b style="color:#555">Allergens found:</b><br/>{chips}</div>'
|
| 141 |
+
f'<div><b style="color:#555">Dietary:</b> {dietary_text}</div>'
|
| 142 |
+
f'{prep_section}'
|
| 143 |
+
)
|
| 144 |
+
return [gr.update(value=_card(body, border))]
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 148 |
+
# 2. fridge_dinner
|
| 149 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 150 |
+
|
| 151 |
+
_FRIDGE_SCHEMA = (
|
| 152 |
+
'{"available": ["eggs", "spinach", "cheddar", "..."], '
|
| 153 |
+
'"dinners": ['
|
| 154 |
+
' {"name": "Spinach Omelette", "uses": ["eggs", "spinach", "cheddar"]}, '
|
| 155 |
+
' {"name": "...", "uses": [...]}, '
|
| 156 |
+
' {"name": "...", "uses": [...]}'
|
| 157 |
+
'], '
|
| 158 |
+
'"use_soon": ["open yogurt", "wilted lettuce", "..."]}'
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
_DINNER_PALETTE = [
|
| 162 |
+
("#1565c0", "#e3f2fd", "🍳"),
|
| 163 |
+
("#2e7d32", "#e8f5e9", "🥘"),
|
| 164 |
+
("#6a1b9a", "#f3e5f5", "🍲"),
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _fridge_instruction() -> str:
|
| 169 |
+
return (
|
| 170 |
+
"You are a creative home chef. Carefully look at this photo of an open fridge or pantry.\n"
|
| 171 |
+
"1. List ALL visible ingredients and food items you can identify "
|
| 172 |
+
"(be specific: 'Greek yogurt' not 'dairy', '2% milk' not 'milk').\n"
|
| 173 |
+
"2. Suggest EXACTLY THREE dinner ideas using ONLY what is visible — "
|
| 174 |
+
"no grocery run required. For each dinner, list which visible ingredients it uses.\n"
|
| 175 |
+
"3. Flag any items that look like they should be used soon: "
|
| 176 |
+
"opened packages, produce past peak freshness, leftovers, etc.\n"
|
| 177 |
+
f"Output schema (JSON only): {_FRIDGE_SCHEMA}"
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def _fridge_render(data) -> list:
|
| 182 |
+
if isinstance(data, dict) and "error" in data:
|
| 183 |
+
return [gr.update(value=_error_html(data.get("raw", data["error"])))]
|
| 184 |
+
|
| 185 |
+
available = data.get("available", [])
|
| 186 |
+
dinners = data.get("dinners", [])
|
| 187 |
+
use_soon = data.get("use_soon", [])
|
| 188 |
+
|
| 189 |
+
# Three dinner cards
|
| 190 |
+
cards_html = ""
|
| 191 |
+
for i, dinner in enumerate(dinners[:3]):
|
| 192 |
+
name = dinner.get("name", f"Dinner {i + 1}") if isinstance(dinner, dict) else str(dinner)
|
| 193 |
+
uses = dinner.get("uses", []) if isinstance(dinner, dict) else []
|
| 194 |
+
color, bg, emoji = _DINNER_PALETTE[i % len(_DINNER_PALETTE)]
|
| 195 |
+
|
| 196 |
+
uses_chips = "".join(
|
| 197 |
+
f'<span style="background:{color}18;color:{color};border:1px solid {color}44;'
|
| 198 |
+
f'padding:2px 8px;border-radius:10px;font-size:12px;'
|
| 199 |
+
f'margin:2px 2px 2px 0;display:inline-block">{ing}</span>'
|
| 200 |
+
for ing in uses
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
cards_html += (
|
| 204 |
+
f'<div style="flex:1;min-width:160px;background:{bg};'
|
| 205 |
+
f'border:2px solid {color}44;border-radius:10px;padding:16px">'
|
| 206 |
+
f'<div style="font-size:24px;margin-bottom:6px">{emoji}</div>'
|
| 207 |
+
f'<div style="font-weight:700;font-size:15px;color:{color};margin-bottom:10px">{name}</div>'
|
| 208 |
+
f'<div style="font-size:11px;color:#757575;margin-bottom:5px;text-transform:uppercase;'
|
| 209 |
+
f'letter-spacing:.5px">Uses</div>'
|
| 210 |
+
f'<div>{uses_chips or "<span style=\'color:#aaa;font-size:12px\'>—</span>"}</div>'
|
| 211 |
+
f'</div>'
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
available_text = ", ".join(available) if available else "Nothing detected"
|
| 215 |
+
|
| 216 |
+
use_soon_chips = "".join(
|
| 217 |
+
f'<span style="background:#fff3e0;color:#e65100;border:1px solid #ff980044;'
|
| 218 |
+
f'padding:3px 10px;border-radius:10px;font-size:13px;'
|
| 219 |
+
f'margin:3px 3px 3px 0;display:inline-block">⚠ {item}</span>'
|
| 220 |
+
for item in use_soon
|
| 221 |
+
)
|
| 222 |
+
use_soon_section = (
|
| 223 |
+
f'<div style="margin-top:20px;padding:14px 16px;background:#fff8e1;'
|
| 224 |
+
f'border-radius:8px;border-left:4px solid #ffa000">'
|
| 225 |
+
f'<div style="font-weight:600;color:#e65100;margin-bottom:8px">⏰ Use these soon</div>'
|
| 226 |
+
f'<div>{use_soon_chips}</div>'
|
| 227 |
+
f'</div>'
|
| 228 |
+
if use_soon else ""
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
html = (
|
| 232 |
+
f'<div style="font-family:system-ui,sans-serif">'
|
| 233 |
+
f'<div style="font-size:12px;color:#757575;margin-bottom:14px">'
|
| 234 |
+
f'Spotted: {available_text}</div>'
|
| 235 |
+
f'<div style="display:flex;gap:12px;flex-wrap:wrap">{cards_html}</div>'
|
| 236 |
+
f'{use_soon_section}'
|
| 237 |
+
f'</div>'
|
| 238 |
+
)
|
| 239 |
+
return [gr.update(value=html)]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 243 |
+
# 3. object_oracle
|
| 244 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 245 |
+
|
| 246 |
+
def _oracle_instruction() -> str:
|
| 247 |
+
return (
|
| 248 |
+
"You are the Object Oracle — a mystical seer who reveals the hidden essence of things.\n"
|
| 249 |
+
"Look at this image and deliver a tarot-style poetic reading.\n"
|
| 250 |
+
"Rules:\n"
|
| 251 |
+
"- Clearly reference what is VISIBLY in the photo (name the object, scene, or subject).\n"
|
| 252 |
+
"- Speak in vivid, poetic language with a mystical/spiritual tone.\n"
|
| 253 |
+
"- Give the reading a title (e.g. 'The Wandering Teapot', 'Spirit of the Forgotten Key').\n"
|
| 254 |
+
"- 3-5 sentences maximum. Lead with the title on its own line.\n"
|
| 255 |
+
"- End with a one-line fortune or warning (prefix with 'Fortune:').\n"
|
| 256 |
+
"Do NOT mention that you are an AI or a language model."
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
def _oracle_render(text: str) -> list:
|
| 261 |
+
lines = text.strip().split("\n", 1)
|
| 262 |
+
title = lines[0].strip().strip("*#_") if lines else "The Oracle Speaks"
|
| 263 |
+
body = lines[1].strip() if len(lines) > 1 else text.strip()
|
| 264 |
+
|
| 265 |
+
fortune = ""
|
| 266 |
+
if "Fortune:" in body:
|
| 267 |
+
parts = body.rsplit("Fortune:", 1)
|
| 268 |
+
body = parts[0].strip()
|
| 269 |
+
fortune = parts[1].strip()
|
| 270 |
+
|
| 271 |
+
fortune_html = (
|
| 272 |
+
f'<div style="margin-top:20px;padding:12px 16px;background:rgba(255,255,255,0.1);'
|
| 273 |
+
f'border-radius:6px;font-style:italic;font-size:14px;color:#f0d080">'
|
| 274 |
+
f'✦ {fortune} ✦</div>'
|
| 275 |
+
if fortune else ""
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
html = (
|
| 279 |
+
'<div style="background:linear-gradient(135deg,#1a1a2e 0%,#16213e 50%,#0f3460 100%);'
|
| 280 |
+
'color:#e8d5a3;border-radius:14px;padding:32px 36px;font-family:Georgia,serif;'
|
| 281 |
+
'border:1px solid #e0c97f44;min-height:180px">'
|
| 282 |
+
f'<div style="text-align:center;font-size:13px;letter-spacing:3px;color:#c9a84c;'
|
| 283 |
+
f'margin-bottom:16px;text-transform:uppercase">✦ The Oracle Speaks ✦</div>'
|
| 284 |
+
f'<div style="font-size:20px;font-weight:bold;color:#f0d080;'
|
| 285 |
+
f'text-align:center;margin-bottom:16px">{title}</div>'
|
| 286 |
+
f'<div style="font-size:16px;line-height:1.9;color:#e0cfa0;white-space:pre-line">{body}</div>'
|
| 287 |
+
f'{fortune_html}'
|
| 288 |
+
f'<div style="text-align:center;margin-top:20px;font-size:22px;color:#c9a84c">⟡</div>'
|
| 289 |
+
'</div>'
|
| 290 |
+
)
|
| 291 |
+
return [gr.update(value=html)]
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 295 |
+
# 4. whats_that_error
|
| 296 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 297 |
+
|
| 298 |
+
_ERROR_SCHEMA = (
|
| 299 |
+
'{"detected_code": "E01", "likely_cause": "...", '
|
| 300 |
+
'"fix_steps": ["Step 1: ...", "Step 2: ..."], "severity": "low|medium|high|critical"}'
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
_SEVERITY_STYLE = {
|
| 304 |
+
"low": ("#2e7d32", "#e8f5e9"),
|
| 305 |
+
"medium": ("#e65100", "#fff3e0"),
|
| 306 |
+
"high": ("#c62828", "#ffebee"),
|
| 307 |
+
"critical": ("#6a1b9a", "#f3e5f5"),
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _error_instruction(appliance: str = "") -> str:
|
| 312 |
+
device_hint = f" on a '{appliance.strip()}'" if appliance.strip() else ""
|
| 313 |
+
return (
|
| 314 |
+
f"You are an appliance and electronics repair expert.\n"
|
| 315 |
+
f"Analyze this photo of an error screen, display panel, or error code{device_hint}.\n"
|
| 316 |
+
f"Identify the error code or fault indicator shown.\n"
|
| 317 |
+
f"Provide: the exact detected code/indicator, the most likely root cause, "
|
| 318 |
+
f"clear numbered fix steps a non-expert can follow, and severity level.\n"
|
| 319 |
+
f"If no code is visible, describe what you see and give general troubleshooting steps.\n"
|
| 320 |
+
f"Output schema: {_ERROR_SCHEMA}"
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def _error_render(data) -> list:
|
| 325 |
+
if isinstance(data, dict) and "error" in data:
|
| 326 |
+
return [gr.update(value=_error_html(data.get("raw", data["error"])))]
|
| 327 |
+
|
| 328 |
+
code = data.get("detected_code", "Unknown")
|
| 329 |
+
cause = data.get("likely_cause", "")
|
| 330 |
+
steps = data.get("fix_steps", [])
|
| 331 |
+
severity = str(data.get("severity", "medium")).lower()
|
| 332 |
+
|
| 333 |
+
color, bg = _SEVERITY_STYLE.get(severity, ("#555", "#f5f5f5"))
|
| 334 |
+
|
| 335 |
+
steps_html = "".join(
|
| 336 |
+
f'<li style="margin:8px 0;color:#333;font-size:14px">{step}</li>'
|
| 337 |
+
for step in (steps if isinstance(steps, list) else [steps])
|
| 338 |
+
)
|
| 339 |
+
|
| 340 |
+
html = (
|
| 341 |
+
f'<div style="font-family:system-ui,sans-serif;max-width:620px">'
|
| 342 |
+
f'<div style="background:{color};color:white;padding:14px 20px;'
|
| 343 |
+
f'border-radius:8px 8px 0 0;display:flex;align-items:center;gap:12px">'
|
| 344 |
+
f'<span style="font-size:22px">⚠️</span>'
|
| 345 |
+
f'<div><div style="font-size:18px;font-weight:700">{code}</div>'
|
| 346 |
+
f'<div style="font-size:12px;opacity:.85;text-transform:uppercase;letter-spacing:1px">'
|
| 347 |
+
f'Severity: {severity}</div></div>'
|
| 348 |
+
f'</div>'
|
| 349 |
+
f'<div style="border:2px solid {color};border-top:none;border-radius:0 0 8px 8px;'
|
| 350 |
+
f'padding:18px 20px;background:{bg}">'
|
| 351 |
+
f'<div style="font-size:13px;font-weight:600;color:#555;margin-bottom:4px">LIKELY CAUSE</div>'
|
| 352 |
+
f'<p style="margin:0 0 16px;font-size:15px;color:#222">{cause}</p>'
|
| 353 |
+
f'<div style="font-size:13px;font-weight:600;color:#555;margin-bottom:6px">FIX STEPS</div>'
|
| 354 |
+
f'<ol style="margin:0;padding-left:22px">{steps_html or "<li>No steps available.</li>"}</ol>'
|
| 355 |
+
f'</div></div>'
|
| 356 |
+
)
|
| 357 |
+
return [gr.update(value=html)]
|
| 358 |
+
|
| 359 |
+
|
| 360 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 361 |
+
# APP_REGISTRY
|
| 362 |
+
# ─────────────────────────────────────────────────────────────────────────────
|
| 363 |
+
|
| 364 |
+
APP_REGISTRY: dict[str, AppSpec] = {
|
| 365 |
+
"allergen_lens": AppSpec(
|
| 366 |
+
app_id="allergen_lens",
|
| 367 |
+
title="🔍 Allergen Lens",
|
| 368 |
+
tagline="Snap a food label — get instant allergen & dietary analysis.",
|
| 369 |
+
instruction_fn=_allergen_instruction,
|
| 370 |
+
output_mode="json",
|
| 371 |
+
input_spec=InputSpec(
|
| 372 |
+
text_label="My allergies / dietary restrictions (optional)",
|
| 373 |
+
text_placeholder="e.g. peanuts, dairy, gluten",
|
| 374 |
+
dropdown_choices=_ALLERGEN_LANGUAGES,
|
| 375 |
+
dropdown_label="Response language",
|
| 376 |
+
dropdown_default="English",
|
| 377 |
+
),
|
| 378 |
+
max_tokens=1024,
|
| 379 |
+
examples=[
|
| 380 |
+
["examples/allergen_label.jpg", "peanuts, dairy", "English"],
|
| 381 |
+
["examples/allergen_label2.jpg", "", "Spanish"],
|
| 382 |
+
],
|
| 383 |
+
render_fn=_allergen_render,
|
| 384 |
+
output_components=["html"],
|
| 385 |
+
theme_color="#2e7d32",
|
| 386 |
+
),
|
| 387 |
+
|
| 388 |
+
"fridge_dinner": AppSpec(
|
| 389 |
+
app_id="fridge_dinner",
|
| 390 |
+
title="🍽️ Fridge Dinner",
|
| 391 |
+
tagline="Photo your fridge — get three dinners you can make right now.",
|
| 392 |
+
instruction_fn=_fridge_instruction,
|
| 393 |
+
output_mode="json",
|
| 394 |
+
input_spec=InputSpec(),
|
| 395 |
+
max_tokens=1024,
|
| 396 |
+
examples=[
|
| 397 |
+
["examples/fridge_dinner.jpg"],
|
| 398 |
+
],
|
| 399 |
+
render_fn=_fridge_render,
|
| 400 |
+
output_components=["html"],
|
| 401 |
+
theme_color="#2e7d32",
|
| 402 |
+
),
|
| 403 |
+
|
| 404 |
+
"object_oracle": AppSpec(
|
| 405 |
+
app_id="object_oracle",
|
| 406 |
+
title="🔮 Object Oracle",
|
| 407 |
+
tagline="Hold up any object — receive its mystical reading.",
|
| 408 |
+
instruction_fn=_oracle_instruction,
|
| 409 |
+
output_mode="text",
|
| 410 |
+
input_spec=InputSpec(),
|
| 411 |
+
max_tokens=512,
|
| 412 |
+
examples=[
|
| 413 |
+
["examples/oracle_object.jpg"],
|
| 414 |
+
],
|
| 415 |
+
render_fn=_oracle_render,
|
| 416 |
+
output_components=["html"],
|
| 417 |
+
theme_color="#4a148c",
|
| 418 |
+
),
|
| 419 |
+
|
| 420 |
+
"whats_that_error": AppSpec(
|
| 421 |
+
app_id="whats_that_error",
|
| 422 |
+
title="🛠️ What's That Error?",
|
| 423 |
+
tagline="Photo your error screen — get a plain-English fix.",
|
| 424 |
+
instruction_fn=_error_instruction,
|
| 425 |
+
output_mode="json",
|
| 426 |
+
input_spec=InputSpec(
|
| 427 |
+
text_label="Appliance or device name (optional)",
|
| 428 |
+
text_placeholder="e.g. Samsung washing machine, iPhone, HP printer",
|
| 429 |
+
),
|
| 430 |
+
max_tokens=1024,
|
| 431 |
+
examples=[
|
| 432 |
+
["examples/error_screen.jpg", ""],
|
| 433 |
+
["examples/error_screen2.jpg", "LG dishwasher"],
|
| 434 |
+
],
|
| 435 |
+
render_fn=_error_render,
|
| 436 |
+
output_components=["html"],
|
| 437 |
+
theme_color="#c62828",
|
| 438 |
+
),
|
| 439 |
+
}
|
core/model.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import spaces # ZeroGPU: must precede torch/transformers imports
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
from PIL import Image
|
| 5 |
+
from transformers import AutoModelForImageTextToText, AutoProcessor
|
| 6 |
+
|
| 7 |
+
MODEL_ID = "openbmb/MiniCPM-V-4.6"
|
| 8 |
+
|
| 9 |
+
processor = AutoProcessor.from_pretrained(MODEL_ID)
|
| 10 |
+
model = AutoModelForImageTextToText.from_pretrained(
|
| 11 |
+
MODEL_ID,
|
| 12 |
+
torch_dtype=torch.bfloat16,
|
| 13 |
+
)
|
| 14 |
+
model = model.to("cuda")
|
| 15 |
+
model.eval()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _to_pil(img) -> Image.Image:
|
| 19 |
+
if isinstance(img, Image.Image):
|
| 20 |
+
return img.convert("RGB")
|
| 21 |
+
if hasattr(img, "__array__"):
|
| 22 |
+
import numpy as np
|
| 23 |
+
arr = img if isinstance(img, np.ndarray) else img.__array__()
|
| 24 |
+
return Image.fromarray(arr).convert("RGB")
|
| 25 |
+
if isinstance(img, str):
|
| 26 |
+
return Image.open(img).convert("RGB")
|
| 27 |
+
raise TypeError(f"Cannot convert {type(img)} to PIL Image")
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@spaces.GPU(duration=120)
|
| 31 |
+
def vision_infer(
|
| 32 |
+
images,
|
| 33 |
+
instruction: str,
|
| 34 |
+
json_mode: bool = False,
|
| 35 |
+
max_tokens: int = 768,
|
| 36 |
+
) -> str:
|
| 37 |
+
"""Single GPU entrypoint. images: PIL Image or list of PIL Images."""
|
| 38 |
+
if not isinstance(images, list):
|
| 39 |
+
images = [images]
|
| 40 |
+
|
| 41 |
+
pil_images = [_to_pil(img) for img in images]
|
| 42 |
+
|
| 43 |
+
if json_mode:
|
| 44 |
+
instruction = (
|
| 45 |
+
instruction
|
| 46 |
+
+ "\n\nRespond with ONLY valid JSON. No markdown fences, no prose, no explanation."
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
content = [{"type": "image", "image": img} for img in pil_images]
|
| 50 |
+
content.append({"type": "text", "text": instruction})
|
| 51 |
+
|
| 52 |
+
messages = [{"role": "user", "content": content}]
|
| 53 |
+
|
| 54 |
+
inputs = processor.apply_chat_template(
|
| 55 |
+
messages,
|
| 56 |
+
tokenize=True,
|
| 57 |
+
add_generation_prompt=True,
|
| 58 |
+
return_dict=True,
|
| 59 |
+
return_tensors="pt",
|
| 60 |
+
downsample_mode="16x",
|
| 61 |
+
max_slice_nums=36,
|
| 62 |
+
).to(model.device)
|
| 63 |
+
|
| 64 |
+
with torch.no_grad():
|
| 65 |
+
generated_ids = model.generate(
|
| 66 |
+
**inputs,
|
| 67 |
+
downsample_mode="16x",
|
| 68 |
+
max_new_tokens=max_tokens,
|
| 69 |
+
do_sample=False,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
trimmed = [
|
| 73 |
+
out_ids[len(in_ids):]
|
| 74 |
+
for in_ids, out_ids in zip(inputs.input_ids, generated_ids)
|
| 75 |
+
]
|
| 76 |
+
return processor.batch_decode(
|
| 77 |
+
trimmed,
|
| 78 |
+
skip_special_tokens=True,
|
| 79 |
+
clean_up_tokenization_spaces=False,
|
| 80 |
+
)[0]
|
core/parse.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import re
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def _strip_fences(text: str) -> str:
|
| 6 |
+
text = text.strip()
|
| 7 |
+
text = re.sub(r"^```(?:json)?\s*\n?", "", text, flags=re.MULTILINE)
|
| 8 |
+
text = re.sub(r"\n?```\s*$", "", text, flags=re.MULTILINE)
|
| 9 |
+
return text.strip()
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _extract_first_json(text: str):
|
| 13 |
+
"""Try to pull the first {...} or [...] block from noisy text."""
|
| 14 |
+
for pattern in (r"\{[\s\S]*\}", r"\[[\s\S]*\]"):
|
| 15 |
+
match = re.search(pattern, text)
|
| 16 |
+
if match:
|
| 17 |
+
try:
|
| 18 |
+
return json.loads(match.group())
|
| 19 |
+
except json.JSONDecodeError:
|
| 20 |
+
pass
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def parse_json(text: str):
|
| 25 |
+
"""
|
| 26 |
+
Defensively parse model JSON output.
|
| 27 |
+
Returns parsed dict/list, or {"error": "...", "raw": "..."} on failure.
|
| 28 |
+
Never raises.
|
| 29 |
+
"""
|
| 30 |
+
cleaned = _strip_fences(text)
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
return json.loads(cleaned)
|
| 34 |
+
except json.JSONDecodeError:
|
| 35 |
+
pass
|
| 36 |
+
|
| 37 |
+
recovered = _extract_first_json(cleaned)
|
| 38 |
+
if recovered is not None:
|
| 39 |
+
return recovered
|
| 40 |
+
|
| 41 |
+
return {"error": "Could not parse model output as JSON.", "raw": text[:600]}
|
examples/allergen_label.jpg
ADDED
|
examples/allergen_label2.jpg
ADDED
|
examples/concierge_event.jpg
ADDED
|
examples/concierge_receipt.jpg
ADDED
|
examples/concierge_screenshot.jpg
ADDED
|
examples/error_screen.jpg
ADDED
|
examples/error_screen2.jpg
ADDED
|
examples/fridge_dinner.jpg
ADDED
|
examples/oracle_object.jpg
ADDED
|
requirements.txt
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio>=4.40.0
|
| 2 |
+
spaces>=0.30.0
|
| 3 |
+
transformers>=4.51.0
|
| 4 |
+
torch>=2.1.0
|
| 5 |
+
Pillow>=10.0.0
|
| 6 |
+
accelerate>=0.26.0
|
| 7 |
+
sentencepiece
|
| 8 |
+
torchvision
|
scripts/fetch_examples.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Download public-domain example images for all four demo apps.
|
| 3 |
+
Run once before first launch: python scripts/fetch_examples.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import urllib.request
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
EXAMPLES_DIR = Path(__file__).parent.parent / "examples"
|
| 10 |
+
EXAMPLES_DIR.mkdir(exist_ok=True)
|
| 11 |
+
|
| 12 |
+
# Each entry: (local_filename, url, description)
|
| 13 |
+
IMAGES = [
|
| 14 |
+
# allergen_lens — food labels
|
| 15 |
+
(
|
| 16 |
+
"allergen_label.jpg",
|
| 17 |
+
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/33/Nutrition_facts_label_example.jpg/640px-Nutrition_facts_label_example.jpg",
|
| 18 |
+
"Nutrition facts label (allergen_lens example 1)",
|
| 19 |
+
),
|
| 20 |
+
(
|
| 21 |
+
"allergen_label2.jpg",
|
| 22 |
+
"https://upload.wikimedia.org/wikipedia/commons/thumb/a/a3/Nutritional_labels.jpg/640px-Nutritional_labels.jpg",
|
| 23 |
+
"Ingredient list label (allergen_lens example 2)",
|
| 24 |
+
),
|
| 25 |
+
# camera_roll_concierge — varied everyday photos
|
| 26 |
+
(
|
| 27 |
+
"concierge_receipt.jpg",
|
| 28 |
+
"https://upload.wikimedia.org/wikipedia/commons/thumb/1/18/Store_receipt.jpg/640px-Store_receipt.jpg",
|
| 29 |
+
"Receipt photo (camera_roll example)",
|
| 30 |
+
),
|
| 31 |
+
(
|
| 32 |
+
"concierge_recipe.jpg",
|
| 33 |
+
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3a/Cat_03.jpg/640px-Cat_03.jpg",
|
| 34 |
+
"Placeholder photo (camera_roll example)",
|
| 35 |
+
),
|
| 36 |
+
# object_oracle — interesting objects
|
| 37 |
+
(
|
| 38 |
+
"oracle_object.jpg",
|
| 39 |
+
"https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png",
|
| 40 |
+
"Interesting object (oracle example)",
|
| 41 |
+
),
|
| 42 |
+
# whats_that_error — error screens
|
| 43 |
+
(
|
| 44 |
+
"error_screen.jpg",
|
| 45 |
+
"https://upload.wikimedia.org/wikipedia/commons/thumb/3/3b/Windows_9X_BSOD.png/640px-Windows_9X_BSOD.png",
|
| 46 |
+
"BSOD error screen (whats_that_error example 1)",
|
| 47 |
+
),
|
| 48 |
+
(
|
| 49 |
+
"error_screen2.jpg",
|
| 50 |
+
"https://upload.wikimedia.org/wikipedia/commons/thumb/e/e5/Windows_XP_BSOD.png/640px-Windows_XP_BSOD.png",
|
| 51 |
+
"XP BSOD error screen (whats_that_error example 2)",
|
| 52 |
+
),
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def fetch_all():
|
| 57 |
+
for filename, url, desc in IMAGES:
|
| 58 |
+
dest = EXAMPLES_DIR / filename
|
| 59 |
+
if dest.exists():
|
| 60 |
+
print(f" skip {filename}")
|
| 61 |
+
continue
|
| 62 |
+
print(f" get {filename} — {desc}")
|
| 63 |
+
try:
|
| 64 |
+
urllib.request.urlretrieve(url, dest)
|
| 65 |
+
print(f" ok {filename}")
|
| 66 |
+
except Exception as exc:
|
| 67 |
+
print(f" FAIL {filename}: {exc}")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
if __name__ == "__main__":
|
| 71 |
+
print(f"Downloading examples to {EXAMPLES_DIR} ...")
|
| 72 |
+
fetch_all()
|
| 73 |
+
print("Done.")
|
scripts/generate_examples.py
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Generate demo example images using PIL only (no network required).
|
| 3 |
+
Run: python scripts/generate_examples.py
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 8 |
+
|
| 9 |
+
EXAMPLES = Path(__file__).parent.parent / "examples"
|
| 10 |
+
EXAMPLES.mkdir(exist_ok=True)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _font(size=16):
|
| 14 |
+
try:
|
| 15 |
+
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", size)
|
| 16 |
+
except Exception:
|
| 17 |
+
try:
|
| 18 |
+
return ImageFont.truetype("/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", size)
|
| 19 |
+
except Exception:
|
| 20 |
+
return ImageFont.load_default()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _font_bold(size=16):
|
| 24 |
+
try:
|
| 25 |
+
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", size)
|
| 26 |
+
except Exception:
|
| 27 |
+
try:
|
| 28 |
+
return ImageFont.truetype("/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", size)
|
| 29 |
+
except Exception:
|
| 30 |
+
return ImageFont.load_default()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# ── allergen_label.jpg ────────────────────────────────────────────────────────
|
| 34 |
+
|
| 35 |
+
def make_allergen_label(path: Path):
|
| 36 |
+
img = Image.new("RGB", (480, 640), "#ffffff")
|
| 37 |
+
d = ImageDraw.Draw(img)
|
| 38 |
+
|
| 39 |
+
# outer border
|
| 40 |
+
d.rectangle([10, 10, 469, 629], outline="#000000", width=3)
|
| 41 |
+
|
| 42 |
+
fb = _font_bold(28)
|
| 43 |
+
fm = _font(14)
|
| 44 |
+
fs = _font(11)
|
| 45 |
+
|
| 46 |
+
y = 24
|
| 47 |
+
d.text((240, y), "Nutrition Facts", font=fb, fill="#000", anchor="mt")
|
| 48 |
+
y += 36
|
| 49 |
+
d.line([14, y, 466, y], fill="#000", width=2)
|
| 50 |
+
y += 8
|
| 51 |
+
d.text((20, y), "Serving Size 1 cup (240ml)", font=fm, fill="#000")
|
| 52 |
+
y += 22
|
| 53 |
+
d.text((20, y), "Servings Per Container: 2", font=fm, fill="#000")
|
| 54 |
+
y += 18
|
| 55 |
+
d.line([14, y, 466, y], fill="#000", width=6)
|
| 56 |
+
y += 8
|
| 57 |
+
d.text((20, y), "Amount Per Serving", font=fs, fill="#000")
|
| 58 |
+
y += 16
|
| 59 |
+
d.text((20, y), "Calories", font=_font_bold(20), fill="#000")
|
| 60 |
+
d.text((300, y), "250", font=_font_bold(30), fill="#000")
|
| 61 |
+
y += 34
|
| 62 |
+
d.line([14, y, 466, y], fill="#000", width=3)
|
| 63 |
+
|
| 64 |
+
nutrients = [
|
| 65 |
+
("Total Fat", "8g", "10%"),
|
| 66 |
+
(" Saturated Fat", "3g", "15%"),
|
| 67 |
+
(" Trans Fat", "0g", ""),
|
| 68 |
+
("Cholesterol", "25mg", "8%"),
|
| 69 |
+
("Sodium", "580mg", "25%"),
|
| 70 |
+
("Total Carbohydrate", "37g", "13%"),
|
| 71 |
+
(" Dietary Fiber", "4g", "14%"),
|
| 72 |
+
(" Total Sugars", "12g", ""),
|
| 73 |
+
("Protein", "5g", ""),
|
| 74 |
+
]
|
| 75 |
+
for name, amt, pct in nutrients:
|
| 76 |
+
y += 4
|
| 77 |
+
d.text((20, y), name, font=fm, fill="#000")
|
| 78 |
+
d.text((340, y), amt, font=fm, fill="#000")
|
| 79 |
+
if pct:
|
| 80 |
+
d.text((420, y), pct, font=fm, fill="#000")
|
| 81 |
+
y += 18
|
| 82 |
+
d.line([14, y, 466, y], fill="#cccccc", width=1)
|
| 83 |
+
|
| 84 |
+
y += 12
|
| 85 |
+
d.line([14, y, 466, y], fill="#000", width=6)
|
| 86 |
+
y += 10
|
| 87 |
+
d.text((20, y), "INGREDIENTS:", font=_font_bold(13), fill="#000")
|
| 88 |
+
y += 18
|
| 89 |
+
ingredients = (
|
| 90 |
+
"Whole milk, enriched flour (wheat flour, niacin, reduced iron,\n"
|
| 91 |
+
"thiamine mononitrate, riboflavin, folic acid), sugar, palm oil,\n"
|
| 92 |
+
"cocoa butter, SOY lecithin, PEANUT oil, modified starch,\n"
|
| 93 |
+
"salt, vanilla extract, MILK protein concentrate."
|
| 94 |
+
)
|
| 95 |
+
for line in ingredients.split("\n"):
|
| 96 |
+
d.text((20, y), line, font=fs, fill="#000")
|
| 97 |
+
y += 15
|
| 98 |
+
|
| 99 |
+
y += 10
|
| 100 |
+
d.line([14, y, 466, y], fill="#000", width=2)
|
| 101 |
+
y += 8
|
| 102 |
+
allergen_text = "CONTAINS: WHEAT, MILK, SOY, PEANUTS"
|
| 103 |
+
d.text((240, y), allergen_text, font=_font_bold(13), fill="#cc0000", anchor="mt")
|
| 104 |
+
|
| 105 |
+
img.save(path, "JPEG", quality=90)
|
| 106 |
+
print(f" created {path.name}")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def make_allergen_label2(path: Path):
|
| 110 |
+
img = Image.new("RGB", (480, 360), "#f9f9f0")
|
| 111 |
+
d = ImageDraw.Draw(img)
|
| 112 |
+
d.rectangle([8, 8, 471, 351], outline="#888", width=2)
|
| 113 |
+
fb = _font_bold(18)
|
| 114 |
+
fm = _font(13)
|
| 115 |
+
|
| 116 |
+
d.text((240, 20), "Ingredients List", font=fb, fill="#222", anchor="mt")
|
| 117 |
+
d.line([10, 44, 470, 44], fill="#888", width=1)
|
| 118 |
+
|
| 119 |
+
lines = [
|
| 120 |
+
"Water, sugar, modified corn starch, citric acid,",
|
| 121 |
+
"natural flavors, sodium benzoate, MILK powder,",
|
| 122 |
+
"caramel color, TREE NUTS (almonds, cashews),",
|
| 123 |
+
"ascorbic acid (Vitamin C), beta-carotene.",
|
| 124 |
+
"",
|
| 125 |
+
"ALLERGEN INFORMATION:",
|
| 126 |
+
"Contains: MILK, TREE NUTS",
|
| 127 |
+
"Manufactured in a facility that also processes EGGS.",
|
| 128 |
+
"",
|
| 129 |
+
"Vegetarian ✓ Gluten-Free ✓ NOT Vegan (contains milk)",
|
| 130 |
+
]
|
| 131 |
+
y = 56
|
| 132 |
+
for line in lines:
|
| 133 |
+
color = "#cc0000" if line.startswith("ALLERGEN") or "Contains:" in line else "#333"
|
| 134 |
+
bold = line.startswith("ALLERGEN") or line.startswith("Vegetarian")
|
| 135 |
+
d.text((20, y), line, font=_font_bold(13) if bold else fm, fill=color)
|
| 136 |
+
y += 20
|
| 137 |
+
|
| 138 |
+
img.save(path, "JPEG", quality=90)
|
| 139 |
+
print(f" created {path.name}")
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ── camera_roll examples ────────────────────────────────────────���─────────────
|
| 143 |
+
|
| 144 |
+
def make_receipt(path: Path):
|
| 145 |
+
img = Image.new("RGB", (380, 520), "#fffef8")
|
| 146 |
+
d = ImageDraw.Draw(img)
|
| 147 |
+
fb = _font_bold(16)
|
| 148 |
+
fm = _font(13)
|
| 149 |
+
fs = _font(11)
|
| 150 |
+
|
| 151 |
+
y = 20
|
| 152 |
+
d.text((190, y), "WHOLE FOODS MARKET", font=fb, fill="#000", anchor="mt")
|
| 153 |
+
y += 22
|
| 154 |
+
d.text((190, y), "Store #0472 · San Francisco, CA", font=fs, fill="#555", anchor="mt")
|
| 155 |
+
y += 18
|
| 156 |
+
d.text((190, y), "Tel: (415) 555-0192", font=fs, fill="#555", anchor="mt")
|
| 157 |
+
y += 24
|
| 158 |
+
d.line([10, y, 370, y], fill="#000", width=1)
|
| 159 |
+
y += 10
|
| 160 |
+
d.text((20, y), "Date: 06/13/2026 10:42 AM", font=fs, fill="#333")
|
| 161 |
+
d.text((370, y), "REG #3", font=fs, fill="#333", anchor="rt")
|
| 162 |
+
y += 20
|
| 163 |
+
d.line([10, y, 370, y], fill="#999", width=1)
|
| 164 |
+
y += 8
|
| 165 |
+
|
| 166 |
+
items = [
|
| 167 |
+
("Organic Whole Milk 1gal", "5.99"),
|
| 168 |
+
("Sourdough Bread", "4.49"),
|
| 169 |
+
("Free Range Eggs 12ct", "6.29"),
|
| 170 |
+
("Mixed Salad Greens 5oz", "3.99"),
|
| 171 |
+
("Sparkling Water 12pk", "11.99"),
|
| 172 |
+
("Dark Chocolate 70%", "3.49"),
|
| 173 |
+
("Almond Butter 16oz", "8.99"),
|
| 174 |
+
]
|
| 175 |
+
for name, price in items:
|
| 176 |
+
d.text((20, y), name, font=fm, fill="#222")
|
| 177 |
+
d.text((360, y), f"${price}", font=fm, fill="#222", anchor="rt")
|
| 178 |
+
y += 20
|
| 179 |
+
|
| 180 |
+
y += 6
|
| 181 |
+
d.line([10, y, 370, y], fill="#000", width=1)
|
| 182 |
+
y += 8
|
| 183 |
+
subtotal = "45.23"
|
| 184 |
+
tax = "3.62"
|
| 185 |
+
total = "48.85"
|
| 186 |
+
for label, val in [("Subtotal", subtotal), ("Tax (8%)", tax)]:
|
| 187 |
+
d.text((20, y), label, font=fm, fill="#444")
|
| 188 |
+
d.text((360, y), f"${val}", font=fm, fill="#444", anchor="rt")
|
| 189 |
+
y += 20
|
| 190 |
+
d.text((20, y), "TOTAL", font=fb, fill="#000")
|
| 191 |
+
d.text((360, y), f"${total}", font=fb, fill="#000", anchor="rt")
|
| 192 |
+
y += 24
|
| 193 |
+
d.line([10, y, 370, y], fill="#000", width=2)
|
| 194 |
+
y += 10
|
| 195 |
+
d.text((190, y), "VISA **** 4821", font=fm, fill="#555", anchor="mt")
|
| 196 |
+
y += 18
|
| 197 |
+
d.text((190, y), "Thank you for shopping!", font=fs, fill="#888", anchor="mt")
|
| 198 |
+
|
| 199 |
+
img.save(path, "JPEG", quality=90)
|
| 200 |
+
print(f" created {path.name}")
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def make_event_flyer(path: Path):
|
| 204 |
+
img = Image.new("RGB", (480, 360), "#1a237e")
|
| 205 |
+
d = ImageDraw.Draw(img)
|
| 206 |
+
|
| 207 |
+
d.rectangle([16, 16, 463, 343], outline="#ffffff", width=2)
|
| 208 |
+
y = 40
|
| 209 |
+
d.text((240, y), "TECH MEETUP", font=_font_bold(32), fill="#ffeb3b", anchor="mt")
|
| 210 |
+
y += 50
|
| 211 |
+
d.text((240, y), "Building Small, Shipping Big", font=_font(18), fill="#e8eaf6", anchor="mt")
|
| 212 |
+
y += 40
|
| 213 |
+
d.line([40, y, 440, y], fill="#3949ab", width=1)
|
| 214 |
+
y += 20
|
| 215 |
+
details = [
|
| 216 |
+
("📅 Date:", "Thursday, June 18, 2026"),
|
| 217 |
+
("🕖 Time:", "6:30 PM – 9:00 PM"),
|
| 218 |
+
("📍 Venue:", "Hugging Face HQ, NYC"),
|
| 219 |
+
("🎟️ RSVP:", "events.hf.co/buildsmall"),
|
| 220 |
+
]
|
| 221 |
+
for label, val in details:
|
| 222 |
+
d.text((60, y), label, font=_font_bold(14), fill="#90caf9")
|
| 223 |
+
d.text((175, y), val, font=_font(14), fill="#ffffff")
|
| 224 |
+
y += 26
|
| 225 |
+
y += 12
|
| 226 |
+
d.text((240, y), "Free admission · Light refreshments provided", font=_font(11), fill="#9fa8da", anchor="mt")
|
| 227 |
+
|
| 228 |
+
img.save(path, "JPEG", quality=90)
|
| 229 |
+
print(f" created {path.name}")
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
def make_screenshot(path: Path):
|
| 233 |
+
img = Image.new("RGB", (640, 400), "#ffffff")
|
| 234 |
+
d = ImageDraw.Draw(img)
|
| 235 |
+
|
| 236 |
+
# Fake browser chrome
|
| 237 |
+
d.rectangle([0, 0, 640, 40], fill="#dee1e6")
|
| 238 |
+
d.ellipse([10, 12, 26, 28], fill="#ff5f57")
|
| 239 |
+
d.ellipse([34, 12, 50, 28], fill="#febc2e")
|
| 240 |
+
d.ellipse([58, 12, 74, 28], fill="#28c840")
|
| 241 |
+
d.rectangle([90, 8, 540, 32], fill="#ffffff", outline="#b8b8b8")
|
| 242 |
+
d.text((315, 20), "https://news.ycombinator.com", font=_font(12), fill="#555", anchor="mm")
|
| 243 |
+
|
| 244 |
+
# Page content
|
| 245 |
+
y = 60
|
| 246 |
+
d.text((20, y), "Hacker News", font=_font_bold(22), fill="#ff6600")
|
| 247 |
+
y += 36
|
| 248 |
+
stories = [
|
| 249 |
+
"Show HN: I built a 1.3B VLM that fits in a ZeroGPU Space (748 pts)",
|
| 250 |
+
"Ask HN: What makes a great hackathon demo? (312 pts)",
|
| 251 |
+
"MiniCPM-V achieves SOTA on OCRBench at 1.3B params (521 pts)",
|
| 252 |
+
"The unreasonable effectiveness of small vision models (189 pts)",
|
| 253 |
+
"From zero to ZeroGPU in 24 hours (245 pts)",
|
| 254 |
+
]
|
| 255 |
+
for i, story in enumerate(stories):
|
| 256 |
+
d.text((20, y), f"{i+1}.", font=_font(13), fill="#828282")
|
| 257 |
+
d.text((44, y), story, font=_font(13), fill="#000000")
|
| 258 |
+
y += 22
|
| 259 |
+
d.text((44, y), f"{(i+1)*47} points by user{i+1} | {i+2} hours ago | {(i+1)*3} comments", font=_font(11), fill="#828282")
|
| 260 |
+
y += 24
|
| 261 |
+
|
| 262 |
+
img.save(path, "JPEG", quality=90)
|
| 263 |
+
print(f" created {path.name}")
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
# ── oracle_object.jpg ─────────────────────────────────────────────────────────
|
| 267 |
+
|
| 268 |
+
def make_oracle_object(path: Path):
|
| 269 |
+
img = Image.new("RGB", (480, 480), "#1c1c2e")
|
| 270 |
+
d = ImageDraw.Draw(img)
|
| 271 |
+
|
| 272 |
+
# Draw a stylized antique key
|
| 273 |
+
cx, cy = 240, 240
|
| 274 |
+
|
| 275 |
+
# Key handle — circle
|
| 276 |
+
d.ellipse([cx-90, cy-180, cx+90, cy-0], outline="#c9a84c", width=8)
|
| 277 |
+
d.ellipse([cx-55, cy-145, cx+55, cy-35], outline="#c9a84c", width=4)
|
| 278 |
+
# Key shaft
|
| 279 |
+
d.rectangle([cx-10, cy-10, cx+10, cy+160], fill="#c9a84c")
|
| 280 |
+
# Key teeth
|
| 281 |
+
for offset in [100, 130, 155]:
|
| 282 |
+
d.rectangle([cx+10, cy+offset, cx+35, cy+offset+14], fill="#c9a84c")
|
| 283 |
+
|
| 284 |
+
# Mystical glow effect (concentric ellipses)
|
| 285 |
+
for r in range(5, 0, -1):
|
| 286 |
+
alpha = 60 - r * 10
|
| 287 |
+
color = (201, 168, 76, alpha)
|
| 288 |
+
size = r * 18
|
| 289 |
+
d.ellipse([cx-size, cy-size, cx+size, cy+size], outline="#c9a84c33")
|
| 290 |
+
|
| 291 |
+
# Stars
|
| 292 |
+
import random
|
| 293 |
+
rng = random.Random(42)
|
| 294 |
+
for _ in range(30):
|
| 295 |
+
x, y_ = rng.randint(10, 470), rng.randint(10, 60)
|
| 296 |
+
d.ellipse([x, y_, x+2, y_+2], fill="#ffffff")
|
| 297 |
+
for _ in range(20):
|
| 298 |
+
x, y_ = rng.randint(10, 470), rng.randint(390, 470)
|
| 299 |
+
d.ellipse([x, y_, x+2, y_+2], fill="#ffffff")
|
| 300 |
+
|
| 301 |
+
d.text((240, 440), "What does this object reveal?", font=_font(13), fill="#9e8fcc", anchor="mt")
|
| 302 |
+
|
| 303 |
+
img.save(path, "JPEG", quality=90)
|
| 304 |
+
print(f" created {path.name}")
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
# ── error screens ─────────────────────────────────────────────────────────────
|
| 308 |
+
|
| 309 |
+
def make_error_screen(path: Path):
|
| 310 |
+
img = Image.new("RGB", (640, 400), "#0a0aff")
|
| 311 |
+
d = ImageDraw.Draw(img)
|
| 312 |
+
|
| 313 |
+
y = 30
|
| 314 |
+
d.text((40, y), ":(", font=_font_bold(80), fill="#ffffff")
|
| 315 |
+
y += 110
|
| 316 |
+
d.text((40, y), "Your PC ran into a problem and needs to restart.", font=_font_bold(20), fill="#ffffff")
|
| 317 |
+
y += 36
|
| 318 |
+
d.text((40, y), "We're just collecting some error info, and then", font=_font(16), fill="#ffffff")
|
| 319 |
+
y += 24
|
| 320 |
+
d.text((40, y), "we'll restart for you.", font=_font(16), fill="#ffffff")
|
| 321 |
+
y += 50
|
| 322 |
+
d.text((40, y), "100% complete", font=_font(16), fill="#ffffff")
|
| 323 |
+
y += 40
|
| 324 |
+
d.text((40, y), "For more information about this issue and possible fixes,", font=_font(13), fill="#ccccff")
|
| 325 |
+
y += 20
|
| 326 |
+
d.text((40, y), "visit https://www.windows.com/stopcode", font=_font(13), fill="#ccccff")
|
| 327 |
+
y += 30
|
| 328 |
+
d.text((40, y), "Stop code: CRITICAL_PROCESS_DIED", font=_font_bold(14), fill="#ffffff")
|
| 329 |
+
y += 24
|
| 330 |
+
d.text((40, y), "What failed: ntoskrnl.exe", font=_font(13), fill="#ccccff")
|
| 331 |
+
|
| 332 |
+
img.save(path, "JPEG", quality=90)
|
| 333 |
+
print(f" created {path.name}")
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def make_error_screen2(path: Path):
|
| 337 |
+
img = Image.new("RGB", (560, 420), "#1a1a1a")
|
| 338 |
+
d = ImageDraw.Draw(img)
|
| 339 |
+
|
| 340 |
+
# Fake washing machine display
|
| 341 |
+
d.rectangle([20, 20, 540, 400], fill="#222222", outline="#444444", width=3)
|
| 342 |
+
d.rectangle([40, 40, 520, 200], fill="#001a33", outline="#0066cc", width=2)
|
| 343 |
+
|
| 344 |
+
# Display panel
|
| 345 |
+
d.text((280, 80), "ERROR", font=_font_bold(40), fill="#ff3300", anchor="mt")
|
| 346 |
+
d.text((280, 130), "E4", font=_font_bold(60), fill="#ff6600", anchor="mt")
|
| 347 |
+
d.text((280, 195), "DOOR OPEN", font=_font_bold(16), fill="#ffaa00", anchor="mt")
|
| 348 |
+
|
| 349 |
+
# Status lights
|
| 350 |
+
for x, col in [(100, "#ff0000"), (200, "#ff0000"), (300, "#888888"), (400, "#888888")]:
|
| 351 |
+
d.ellipse([x-8, 220, x+8, 236], fill=col)
|
| 352 |
+
|
| 353 |
+
y = 260
|
| 354 |
+
d.text((280, y), "Samsung WF45T6000AW", font=_font(13), fill="#888888", anchor="mt")
|
| 355 |
+
y += 22
|
| 356 |
+
d.text((280, y), "Washing Machine", font=_font(12), fill="#666666", anchor="mt")
|
| 357 |
+
y += 30
|
| 358 |
+
d.text((280, y), "Press POWER to reset after fixing", font=_font(11), fill="#555555", anchor="mt")
|
| 359 |
+
|
| 360 |
+
# Physical buttons
|
| 361 |
+
for x, label in [(120, "POWER"), (200, "START"), (290, "PAUSE"), (380, "MODE"), (460, "TEMP")]:
|
| 362 |
+
d.ellipse([x-20, 330, x+20, 370], fill="#333333", outline="#555555")
|
| 363 |
+
d.text((x, 395), label, font=_font(9), fill="#666666", anchor="mt")
|
| 364 |
+
|
| 365 |
+
img.save(path, "JPEG", quality=90)
|
| 366 |
+
print(f" created {path.name}")
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
# ── Main ──────────────────────────────────────────────────────────────────────
|
| 370 |
+
|
| 371 |
+
TASKS = [
|
| 372 |
+
("allergen_label.jpg", make_allergen_label),
|
| 373 |
+
("allergen_label2.jpg", make_allergen_label2),
|
| 374 |
+
("concierge_receipt.jpg", make_receipt),
|
| 375 |
+
("concierge_event.jpg", make_event_flyer),
|
| 376 |
+
("concierge_screenshot.jpg", make_screenshot),
|
| 377 |
+
("oracle_object.jpg", make_oracle_object),
|
| 378 |
+
("error_screen.jpg", make_error_screen),
|
| 379 |
+
("error_screen2.jpg", make_error_screen2),
|
| 380 |
+
]
|
| 381 |
+
|
| 382 |
+
if __name__ == "__main__":
|
| 383 |
+
print(f"Generating examples in {EXAMPLES} ...")
|
| 384 |
+
for filename, fn in TASKS:
|
| 385 |
+
p = EXAMPLES / filename
|
| 386 |
+
fn(p)
|
| 387 |
+
print("Done.")
|