Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
"""
|
| 2 |
Amazon Trailer Inspector
|
| 3 |
-
HuggingFace Spaces + Gradio pipeline
|
| 4 |
Gemma-3 (primary) β Llama-3.2-Vision β Qwen2.5-VL (fallbacks)
|
| 5 |
-
Parallel multi-image inference, clean results UI.
|
| 6 |
"""
|
| 7 |
|
| 8 |
import gradio as gr
|
|
@@ -13,15 +12,15 @@ import re
|
|
| 13 |
import os
|
| 14 |
from PIL import Image
|
| 15 |
import io
|
| 16 |
-
from huggingface_hub import InferenceClient
|
| 17 |
|
| 18 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 19 |
-
# Model chain (
|
| 20 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 21 |
MODELS = [
|
| 22 |
-
"
|
| 23 |
-
"
|
| 24 |
-
"
|
| 25 |
]
|
| 26 |
|
| 27 |
DETECTION_PROMPT = """You are a precise visual inspector for Amazon trailer fleets.
|
|
@@ -32,7 +31,7 @@ Carefully examine the trailer image and locate these 4 components:
|
|
| 32 |
3. PRIME_LOGO β The Amazon Prime logo: blue swooping arrow/checkmark. Can be full or partial, on rear or side.
|
| 33 |
4. TRAILER_ID β A vertical fluorescent green or yellow-green ID label strip on the corner post (shows a number like SV2602705).
|
| 34 |
|
| 35 |
-
Reply ONLY with valid JSON β
|
| 36 |
{
|
| 37 |
"sensors": {"found": true, "confidence": "high", "notes": "two diamond plates visible lower-left"},
|
| 38 |
"gps_device": {"found": false, "confidence": "medium", "notes": "top corner obscured"},
|
|
@@ -42,52 +41,111 @@ Reply ONLY with valid JSON β absolutely no extra text, no markdown code fences
|
|
| 42 |
|
| 43 |
KEYS = ["sensors", "gps_device", "prime_logo", "trailer_id"]
|
| 44 |
|
|
|
|
| 45 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
-
#
|
| 47 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
|
| 49 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
if max(img.size) > max_side:
|
| 51 |
img = img.copy()
|
| 52 |
img.thumbnail((max_side, max_side), Image.LANCZOS)
|
| 53 |
buf = io.BytesIO()
|
| 54 |
-
img.save(buf, format="JPEG", quality=
|
| 55 |
return base64.b64encode(buf.getvalue()).decode()
|
| 56 |
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
def call_model(img: Image.Image, model: str) -> dict:
|
| 59 |
-
"""
|
| 60 |
-
token
|
| 61 |
-
client = InferenceClient(
|
| 62 |
-
b64
|
| 63 |
|
| 64 |
messages = [{
|
| 65 |
"role": "user",
|
| 66 |
"content": [
|
| 67 |
-
{
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
],
|
| 70 |
}]
|
| 71 |
|
| 72 |
-
resp = client.chat_completion(
|
| 73 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
-
m = re.search(r
|
| 76 |
if not m:
|
| 77 |
-
raise ValueError(f"
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
|
| 81 |
def analyze_one(img: Image.Image) -> tuple:
|
| 82 |
-
"""
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
for model in MODELS:
|
|
|
|
| 85 |
try:
|
| 86 |
result = call_model(img, model)
|
| 87 |
-
return result,
|
| 88 |
except Exception as e:
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
|
| 93 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -95,15 +153,8 @@ def analyze_one(img: Image.Image) -> tuple:
|
|
| 95 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 96 |
|
| 97 |
def merge(results: list) -> dict:
|
| 98 |
-
"""
|
| 99 |
-
Union across all images:
|
| 100 |
-
- component is FOUND if any image found it
|
| 101 |
-
- highest confidence wins
|
| 102 |
-
- first non-empty notes kept
|
| 103 |
-
"""
|
| 104 |
RANK = {"high": 3, "medium": 2, "low": 1, "": 0}
|
| 105 |
merged = {k: {"found": False, "confidence": "low", "notes": ""} for k in KEYS}
|
| 106 |
-
|
| 107 |
for res in results:
|
| 108 |
if not res:
|
| 109 |
continue
|
|
@@ -119,14 +170,10 @@ def merge(results: list) -> dict:
|
|
| 119 |
|
| 120 |
|
| 121 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 122 |
-
#
|
| 123 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 124 |
|
| 125 |
-
def load_images(file_paths):
|
| 126 |
-
"""
|
| 127 |
-
HF Spaces Gradio 5.x: gr.File(type='filepath') returns list[str].
|
| 128 |
-
Handles string paths and legacy file-object fallback.
|
| 129 |
-
"""
|
| 130 |
imgs = []
|
| 131 |
if not file_paths:
|
| 132 |
return imgs
|
|
@@ -134,28 +181,31 @@ def load_images(file_paths):
|
|
| 134 |
file_paths = [file_paths]
|
| 135 |
for p in file_paths:
|
| 136 |
try:
|
| 137 |
-
path = p if isinstance(p, str) else
|
| 138 |
imgs.append(Image.open(path).convert("RGB"))
|
| 139 |
except Exception as e:
|
| 140 |
print(f"[load] skipped {p}: {e}")
|
| 141 |
return imgs
|
| 142 |
|
| 143 |
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
Returns: (result_html: str, status_html: str)
|
| 148 |
-
"""
|
| 149 |
-
images = load_images(file_paths)
|
| 150 |
|
| 151 |
-
|
|
|
|
|
|
|
| 152 |
return (
|
| 153 |
-
|
| 154 |
-
_status("
|
| 155 |
)
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
n = len(images)
|
| 158 |
-
all_results,
|
| 159 |
|
| 160 |
with concurrent.futures.ThreadPoolExecutor(max_workers=min(n, 4)) as pool:
|
| 161 |
futs = [pool.submit(analyze_one, img) for img in images]
|
|
@@ -165,23 +215,29 @@ def analyze(file_paths):
|
|
| 165 |
all_results.append(res)
|
| 166 |
models_used.add(meta)
|
| 167 |
else:
|
| 168 |
-
|
| 169 |
|
| 170 |
if not all_results:
|
|
|
|
|
|
|
| 171 |
return (
|
| 172 |
-
_error(
|
| 173 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
_status("error"),
|
| 175 |
)
|
| 176 |
|
| 177 |
merged = merge(all_results)
|
| 178 |
model_str = " Β· ".join(sorted(models_used)) or "AI"
|
| 179 |
-
warn = (f"<br><small style='color:#d97706;'>β οΈ {len(
|
| 180 |
-
if
|
| 181 |
-
result_h = build_cards(merged, n, model_str, warn)
|
| 182 |
-
status_h = _status("done", n, len(all_results))
|
| 183 |
|
| 184 |
-
return
|
| 185 |
|
| 186 |
|
| 187 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -214,12 +270,12 @@ def build_cards(merged: dict, img_n: int, model_str: str, warn: str) -> str:
|
|
| 214 |
conf = d.get("confidence", "low")
|
| 215 |
notes = d.get("notes", "")
|
| 216 |
|
| 217 |
-
rbg
|
| 218 |
-
rbd
|
| 219 |
-
stc
|
| 220 |
-
stx
|
| 221 |
-
cdc
|
| 222 |
-
|
| 223 |
f'<div style="margin-top:8px;padding-top:8px;border-top:1px solid {rbd};'
|
| 224 |
f'font-size:12px;color:#4b5563;font-style:italic;line-height:1.5;">"{notes}"</div>'
|
| 225 |
if notes else ""
|
|
@@ -234,7 +290,7 @@ def build_cards(merged: dict, img_n: int, model_str: str, warn: str) -> str:
|
|
| 234 |
<div style="flex:1;min-width:0;">
|
| 235 |
<div style="font-weight:700;font-size:14px;color:#111827;">{name}</div>
|
| 236 |
<div style="font-size:11px;color:#9ca3af;margin-top:1px;">{desc}</div>
|
| 237 |
-
{
|
| 238 |
</div>
|
| 239 |
<div style="text-align:right;flex-shrink:0;padding-left:8px;">
|
| 240 |
<div style="font-weight:700;color:{stc};font-size:13px;white-space:nowrap;">{stx}</div>
|
|
@@ -253,7 +309,7 @@ def build_cards(merged: dict, img_n: int, model_str: str, warn: str) -> str:
|
|
| 253 |
{si} {found_n}/{total} β {sl}
|
| 254 |
</div>
|
| 255 |
<div style="font-size:12px;color:#6b7280;margin-top:3px;">
|
| 256 |
-
{img_n} image{'s' if img_n>1 else ''} Β· {model_str}{warn}
|
| 257 |
</div>
|
| 258 |
</div>
|
| 259 |
<div style="font-size:36px;">π</div>
|
|
@@ -264,8 +320,7 @@ def build_cards(merged: dict, img_n: int, model_str: str, warn: str) -> str:
|
|
| 264 |
|
| 265 |
def _placeholder() -> str:
|
| 266 |
return """
|
| 267 |
-
<div style="text-align:center;padding:60px 20px;color:#94a3b8;
|
| 268 |
-
font-family:-apple-system,sans-serif;">
|
| 269 |
<div style="font-size:48px;margin-bottom:14px;">π·</div>
|
| 270 |
<div style="font-size:15px;font-weight:600;color:#64748b;">Upload trailer images to begin</div>
|
| 271 |
<div style="font-size:13px;margin-top:6px;">Front view, rear view, or both β all work</div>
|
|
@@ -275,31 +330,50 @@ def _placeholder() -> str:
|
|
| 275 |
def _status(state: str, total: int = 0, ok: int = 0) -> str:
|
| 276 |
msgs = {
|
| 277 |
"idle": ("π‘", "#d97706", "Waiting for images"),
|
| 278 |
-
"done": ("π’", "#16a34a", f"{ok}/{total} image{'s' if total>1 else ''} processed"),
|
| 279 |
-
"error": ("π΄", "#dc2626", "
|
| 280 |
}
|
| 281 |
icon, color, text = msgs.get(state, msgs["idle"])
|
| 282 |
return (
|
| 283 |
-
f'<div style="font-size:12px;color:{color};text-align:center;'
|
| 284 |
-
f'
|
| 285 |
)
|
| 286 |
|
| 287 |
|
| 288 |
def _error(msg: str) -> str:
|
| 289 |
return (
|
| 290 |
f'<div style="background:#fef2f2;border:1.5px solid #fca5a5;border-radius:12px;'
|
| 291 |
-
f'padding:20px;color:#b91c1c;font-family:sans-serif;font-size:
|
| 292 |
-
f'
|
| 293 |
)
|
| 294 |
|
| 295 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 297 |
# Gradio UI
|
| 298 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 299 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
CSS = """
|
| 301 |
.gradio-container { max-width: 980px !important; margin: auto !important; }
|
| 302 |
-
#upload-box .wrap { border-radius: 12px !important; min-height: 120px; }
|
| 303 |
#analyze-btn { font-size: 15px !important; font-weight: 700 !important;
|
| 304 |
letter-spacing: .02em; border-radius: 10px !important; }
|
| 305 |
footer { display: none !important; }
|
|
@@ -313,8 +387,7 @@ THEME = gr.themes.Soft(
|
|
| 313 |
|
| 314 |
with gr.Blocks(title="π Amazon Trailer Inspector", theme=THEME, css=CSS) as demo:
|
| 315 |
|
| 316 |
-
|
| 317 |
-
gr.HTML("""
|
| 318 |
<div style="text-align:center;padding:30px 0 18px;font-family:sans-serif;">
|
| 319 |
<div style="font-size:46px;margin-bottom:10px;">π</div>
|
| 320 |
<h1 style="font-size:26px;font-weight:800;color:#0f172a;margin:0 0 6px;">
|
|
@@ -323,14 +396,12 @@ with gr.Blocks(title="π Amazon Trailer Inspector", theme=THEME, css=CSS) as d
|
|
| 323 |
<p style="color:#64748b;font-size:14px;margin:0;">
|
| 324 |
AI-powered verification of required trailer components from photos
|
| 325 |
</p>
|
| 326 |
-
</div>
|
|
|
|
| 327 |
|
| 328 |
-
# ββ Two-column layout βββββββββββββββββββββββββββββββββββββ
|
| 329 |
with gr.Row(equal_height=False):
|
| 330 |
|
| 331 |
-
# Left β upload + checklist
|
| 332 |
with gr.Column(scale=1, min_width=280):
|
| 333 |
-
|
| 334 |
gr.HTML("""
|
| 335 |
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;
|
| 336 |
padding:16px 18px;margin-bottom:14px;">
|
|
@@ -340,19 +411,19 @@ with gr.Blocks(title="π Amazon Trailer Inspector", theme=THEME, css=CSS) as d
|
|
| 340 |
</div>
|
| 341 |
<div style="display:grid;gap:9px;font-size:13px;color:#334155;">
|
| 342 |
<div style="display:flex;align-items:center;gap:10px;">
|
| 343 |
-
<span style="background:#fef3c7;border-radius:7px;padding:4px 9px;
|
| 344 |
<span><b>Sensors</b> β two diamond-shaped plates</span>
|
| 345 |
</div>
|
| 346 |
<div style="display:flex;align-items:center;gap:10px;">
|
| 347 |
-
<span style="background:#dbeafe;border-radius:7px;padding:4px 9px;
|
| 348 |
<span><b>GPS Device</b> β white box, top corner</span>
|
| 349 |
</div>
|
| 350 |
<div style="display:flex;align-items:center;gap:10px;">
|
| 351 |
-
<span style="background:#ede9fe;border-radius:7px;padding:4px 9px;
|
| 352 |
<span><b>Prime Logo</b> β Amazon Prime mark</span>
|
| 353 |
</div>
|
| 354 |
<div style="display:flex;align-items:center;gap:10px;">
|
| 355 |
-
<span style="background:#d1fae5;border-radius:7px;padding:4px 9px;
|
| 356 |
<span><b>Trailer ID</b> β corner post label strip</span>
|
| 357 |
</div>
|
| 358 |
</div>
|
|
@@ -362,8 +433,7 @@ with gr.Blocks(title="π Amazon Trailer Inspector", theme=THEME, css=CSS) as d
|
|
| 362 |
label="Upload Trailer Image(s)",
|
| 363 |
file_count="multiple",
|
| 364 |
file_types=["image"],
|
| 365 |
-
type="filepath",
|
| 366 |
-
elem_id="upload-box",
|
| 367 |
)
|
| 368 |
|
| 369 |
gr.HTML("""
|
|
@@ -380,25 +450,20 @@ with gr.Blocks(title="π Amazon Trailer Inspector", theme=THEME, css=CSS) as d
|
|
| 380 |
|
| 381 |
status_html = gr.HTML(_status("idle"))
|
| 382 |
|
| 383 |
-
# Right β results panel
|
| 384 |
with gr.Column(scale=1, min_width=320):
|
| 385 |
result_html = gr.HTML(_placeholder())
|
| 386 |
|
| 387 |
-
# ββ Footer ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 388 |
gr.HTML("""
|
| 389 |
<div style="text-align:center;padding:20px 0 10px;color:#94a3b8;
|
| 390 |
font-size:12px;font-family:sans-serif;">
|
| 391 |
-
|
| 392 |
-
Images processed in parallel | No data
|
| 393 |
</div>""")
|
| 394 |
|
| 395 |
-
# ββ Wiring ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 396 |
analyze_btn.click(
|
| 397 |
fn=analyze,
|
| 398 |
inputs=[file_input],
|
| 399 |
outputs=[result_html, status_html],
|
| 400 |
)
|
| 401 |
|
| 402 |
-
|
| 403 |
-
# HF Spaces handles host/port β no arguments needed
|
| 404 |
demo.launch()
|
|
|
|
| 1 |
"""
|
| 2 |
Amazon Trailer Inspector
|
| 3 |
+
HuggingFace Spaces + Gradio 5.x pipeline
|
| 4 |
Gemma-3 (primary) β Llama-3.2-Vision β Qwen2.5-VL (fallbacks)
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
import gradio as gr
|
|
|
|
| 12 |
import os
|
| 13 |
from PIL import Image
|
| 14 |
import io
|
| 15 |
+
from huggingface_hub import InferenceClient, HfApi
|
| 16 |
|
| 17 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 18 |
+
# Model chain (tried in order, first success wins)
|
| 19 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 20 |
MODELS = [
|
| 21 |
+
"meta-llama/Llama-3.2-11B-Vision-Instruct", # Most reliable free vision model
|
| 22 |
+
"Qwen/Qwen2.5-VL-7B-Instruct", # Fallback 1
|
| 23 |
+
"google/gemma-3-4b-it", # Fallback 2
|
| 24 |
]
|
| 25 |
|
| 26 |
DETECTION_PROMPT = """You are a precise visual inspector for Amazon trailer fleets.
|
|
|
|
| 31 |
3. PRIME_LOGO β The Amazon Prime logo: blue swooping arrow/checkmark. Can be full or partial, on rear or side.
|
| 32 |
4. TRAILER_ID β A vertical fluorescent green or yellow-green ID label strip on the corner post (shows a number like SV2602705).
|
| 33 |
|
| 34 |
+
Reply ONLY with valid JSON β no extra text, no markdown fences:
|
| 35 |
{
|
| 36 |
"sensors": {"found": true, "confidence": "high", "notes": "two diamond plates visible lower-left"},
|
| 37 |
"gps_device": {"found": false, "confidence": "medium", "notes": "top corner obscured"},
|
|
|
|
| 41 |
|
| 42 |
KEYS = ["sensors", "gps_device", "prime_logo", "trailer_id"]
|
| 43 |
|
| 44 |
+
|
| 45 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 46 |
+
# Token validation (runs once at startup)
|
| 47 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 48 |
|
| 49 |
+
def check_token() -> tuple[bool, str]:
|
| 50 |
+
token = os.environ.get("HF_TOKEN", "").strip()
|
| 51 |
+
if not token:
|
| 52 |
+
return False, "HF_TOKEN secret is not set. Go to Space Settings β Repository Secrets β add HF_TOKEN."
|
| 53 |
+
try:
|
| 54 |
+
api = HfApi(token=token)
|
| 55 |
+
api.whoami()
|
| 56 |
+
return True, "Token OK"
|
| 57 |
+
except Exception as e:
|
| 58 |
+
return False, f"HF_TOKEN is invalid or expired: {e}"
|
| 59 |
+
|
| 60 |
+
TOKEN_OK, TOKEN_MSG = check_token()
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 64 |
+
# Image helpers
|
| 65 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 66 |
+
|
| 67 |
+
def pil_to_b64(img: Image.Image, max_side: int = 1024) -> str:
|
| 68 |
+
"""Resize and encode to base64 JPEG."""
|
| 69 |
if max(img.size) > max_side:
|
| 70 |
img = img.copy()
|
| 71 |
img.thumbnail((max_side, max_side), Image.LANCZOS)
|
| 72 |
buf = io.BytesIO()
|
| 73 |
+
img.save(buf, format="JPEG", quality=85)
|
| 74 |
return base64.b64encode(buf.getvalue()).decode()
|
| 75 |
|
| 76 |
|
| 77 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 78 |
+
# LLM call β with detailed error capture
|
| 79 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 80 |
+
|
| 81 |
def call_model(img: Image.Image, model: str) -> dict:
|
| 82 |
+
"""Call one vision LLM. Raises ValueError with a descriptive message on failure."""
|
| 83 |
+
token = os.environ.get("HF_TOKEN", "").strip() or None
|
| 84 |
+
client = InferenceClient(token=token)
|
| 85 |
+
b64 = pil_to_b64(img)
|
| 86 |
|
| 87 |
messages = [{
|
| 88 |
"role": "user",
|
| 89 |
"content": [
|
| 90 |
+
{
|
| 91 |
+
"type": "image_url",
|
| 92 |
+
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
|
| 93 |
+
},
|
| 94 |
+
{
|
| 95 |
+
"type": "text",
|
| 96 |
+
"text": DETECTION_PROMPT,
|
| 97 |
+
},
|
| 98 |
],
|
| 99 |
}]
|
| 100 |
|
| 101 |
+
resp = client.chat_completion(
|
| 102 |
+
model=model,
|
| 103 |
+
messages=messages,
|
| 104 |
+
max_tokens=512,
|
| 105 |
+
temperature=0.05,
|
| 106 |
+
)
|
| 107 |
+
raw = resp.choices[0].message.content.strip()
|
| 108 |
+
|
| 109 |
+
# Strip accidental markdown fences
|
| 110 |
+
raw = re.sub(r"^```(?:json)?", "", raw).strip()
|
| 111 |
+
raw = re.sub(r"```$", "", raw).strip()
|
| 112 |
|
| 113 |
+
m = re.search(r"\{[\s\S]*\}", raw)
|
| 114 |
if not m:
|
| 115 |
+
raise ValueError(f"Model returned no JSON.\nRaw output: {raw[:300]}")
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
return json.loads(m.group())
|
| 119 |
+
except json.JSONDecodeError as e:
|
| 120 |
+
raise ValueError(f"JSON parse error: {e}\nRaw: {m.group()[:300]}")
|
| 121 |
|
| 122 |
|
| 123 |
def analyze_one(img: Image.Image) -> tuple:
|
| 124 |
+
"""
|
| 125 |
+
Try each model in MODELS order.
|
| 126 |
+
Returns (result_dict, model_short_name) on success,
|
| 127 |
+
(None, error_summary_string) on total failure.
|
| 128 |
+
"""
|
| 129 |
+
attempt_log = []
|
| 130 |
for model in MODELS:
|
| 131 |
+
short = model.split("/")[-1]
|
| 132 |
try:
|
| 133 |
result = call_model(img, model)
|
| 134 |
+
return result, short
|
| 135 |
except Exception as e:
|
| 136 |
+
msg = str(e)
|
| 137 |
+
# Shorten common HTTP error noise
|
| 138 |
+
if "429" in msg:
|
| 139 |
+
msg = "rate-limited (429)"
|
| 140 |
+
elif "401" in msg or "403" in msg:
|
| 141 |
+
msg = "auth error β check HF_TOKEN"
|
| 142 |
+
elif "404" in msg:
|
| 143 |
+
msg = "model not found (404)"
|
| 144 |
+
elif "503" in msg or "502" in msg:
|
| 145 |
+
msg = "model loading / unavailable"
|
| 146 |
+
attempt_log.append(f"{short}: {msg}")
|
| 147 |
+
|
| 148 |
+
return None, " | ".join(attempt_log)
|
| 149 |
|
| 150 |
|
| 151 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 153 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 154 |
|
| 155 |
def merge(results: list) -> dict:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
RANK = {"high": 3, "medium": 2, "low": 1, "": 0}
|
| 157 |
merged = {k: {"found": False, "confidence": "low", "notes": ""} for k in KEYS}
|
|
|
|
| 158 |
for res in results:
|
| 159 |
if not res:
|
| 160 |
continue
|
|
|
|
| 170 |
|
| 171 |
|
| 172 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 173 |
+
# Load images from Gradio 5.x file paths
|
| 174 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 175 |
|
| 176 |
+
def load_images(file_paths) -> list:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
imgs = []
|
| 178 |
if not file_paths:
|
| 179 |
return imgs
|
|
|
|
| 181 |
file_paths = [file_paths]
|
| 182 |
for p in file_paths:
|
| 183 |
try:
|
| 184 |
+
path = p if isinstance(p, str) else getattr(p, "name", str(p))
|
| 185 |
imgs.append(Image.open(path).convert("RGB"))
|
| 186 |
except Exception as e:
|
| 187 |
print(f"[load] skipped {p}: {e}")
|
| 188 |
return imgs
|
| 189 |
|
| 190 |
|
| 191 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 192 |
+
# Main Gradio callback
|
| 193 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
| 194 |
|
| 195 |
+
def analyze(file_paths):
|
| 196 |
+
# ββ Token guard ββ
|
| 197 |
+
if not TOKEN_OK:
|
| 198 |
return (
|
| 199 |
+
_error(f"<b>Setup required:</b> {TOKEN_MSG}"),
|
| 200 |
+
_status("error"),
|
| 201 |
)
|
| 202 |
|
| 203 |
+
images = load_images(file_paths)
|
| 204 |
+
if not images:
|
| 205 |
+
return _placeholder(), _status("idle")
|
| 206 |
+
|
| 207 |
n = len(images)
|
| 208 |
+
all_results, all_errors, models_used = [], [], set()
|
| 209 |
|
| 210 |
with concurrent.futures.ThreadPoolExecutor(max_workers=min(n, 4)) as pool:
|
| 211 |
futs = [pool.submit(analyze_one, img) for img in images]
|
|
|
|
| 215 |
all_results.append(res)
|
| 216 |
models_used.add(meta)
|
| 217 |
else:
|
| 218 |
+
all_errors.append(meta)
|
| 219 |
|
| 220 |
if not all_results:
|
| 221 |
+
# Show the REAL error from each model attempt
|
| 222 |
+
err_detail = "<br>".join(all_errors) if all_errors else "Unknown error"
|
| 223 |
return (
|
| 224 |
+
_error(
|
| 225 |
+
f"<b>All models failed.</b><br><br>"
|
| 226 |
+
f"<code style='font-size:12px;line-height:1.8;'>{err_detail}</code><br><br>"
|
| 227 |
+
f"Common causes:<br>"
|
| 228 |
+
f"β’ HF_TOKEN missing/expired β Space Settings β Secrets<br>"
|
| 229 |
+
f"β’ Models overloaded (rate limit 429) β retry in a minute<br>"
|
| 230 |
+
f"β’ Image too large β try a smaller/compressed photo"
|
| 231 |
+
),
|
| 232 |
_status("error"),
|
| 233 |
)
|
| 234 |
|
| 235 |
merged = merge(all_results)
|
| 236 |
model_str = " Β· ".join(sorted(models_used)) or "AI"
|
| 237 |
+
warn = (f"<br><small style='color:#d97706;'>β οΈ {len(all_errors)} image(s) failed: "
|
| 238 |
+
f"{all_errors[0][:80]}</small>" if all_errors else "")
|
|
|
|
|
|
|
| 239 |
|
| 240 |
+
return build_cards(merged, n, model_str, warn), _status("done", n, len(all_results))
|
| 241 |
|
| 242 |
|
| 243 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 270 |
conf = d.get("confidence", "low")
|
| 271 |
notes = d.get("notes", "")
|
| 272 |
|
| 273 |
+
rbg = "#f0fdf4" if found else "#fef2f2"
|
| 274 |
+
rbd = "#bbf7d0" if found else "#fecaca"
|
| 275 |
+
stc = "#15803d" if found else "#b91c1c"
|
| 276 |
+
stx = "β
Found" if found else "β Missing"
|
| 277 |
+
cdc = {"high": "#16a34a", "medium": "#d97706", "low": "#dc2626"}.get(conf, "#9ca3af")
|
| 278 |
+
note_html = (
|
| 279 |
f'<div style="margin-top:8px;padding-top:8px;border-top:1px solid {rbd};'
|
| 280 |
f'font-size:12px;color:#4b5563;font-style:italic;line-height:1.5;">"{notes}"</div>'
|
| 281 |
if notes else ""
|
|
|
|
| 290 |
<div style="flex:1;min-width:0;">
|
| 291 |
<div style="font-weight:700;font-size:14px;color:#111827;">{name}</div>
|
| 292 |
<div style="font-size:11px;color:#9ca3af;margin-top:1px;">{desc}</div>
|
| 293 |
+
{note_html}
|
| 294 |
</div>
|
| 295 |
<div style="text-align:right;flex-shrink:0;padding-left:8px;">
|
| 296 |
<div style="font-weight:700;color:{stc};font-size:13px;white-space:nowrap;">{stx}</div>
|
|
|
|
| 309 |
{si} {found_n}/{total} β {sl}
|
| 310 |
</div>
|
| 311 |
<div style="font-size:12px;color:#6b7280;margin-top:3px;">
|
| 312 |
+
{img_n} image{'s' if img_n > 1 else ''} Β· {model_str}{warn}
|
| 313 |
</div>
|
| 314 |
</div>
|
| 315 |
<div style="font-size:36px;">π</div>
|
|
|
|
| 320 |
|
| 321 |
def _placeholder() -> str:
|
| 322 |
return """
|
| 323 |
+
<div style="text-align:center;padding:60px 20px;color:#94a3b8;font-family:sans-serif;">
|
|
|
|
| 324 |
<div style="font-size:48px;margin-bottom:14px;">π·</div>
|
| 325 |
<div style="font-size:15px;font-weight:600;color:#64748b;">Upload trailer images to begin</div>
|
| 326 |
<div style="font-size:13px;margin-top:6px;">Front view, rear view, or both β all work</div>
|
|
|
|
| 330 |
def _status(state: str, total: int = 0, ok: int = 0) -> str:
|
| 331 |
msgs = {
|
| 332 |
"idle": ("π‘", "#d97706", "Waiting for images"),
|
| 333 |
+
"done": ("π’", "#16a34a", f"{ok}/{total} image{'s' if total > 1 else ''} processed"),
|
| 334 |
+
"error": ("π΄", "#dc2626", "See error details β"),
|
| 335 |
}
|
| 336 |
icon, color, text = msgs.get(state, msgs["idle"])
|
| 337 |
return (
|
| 338 |
+
f'<div style="font-size:12px;color:{color};text-align:center;padding:6px 0 2px;">'
|
| 339 |
+
f'{icon} {text}</div>'
|
| 340 |
)
|
| 341 |
|
| 342 |
|
| 343 |
def _error(msg: str) -> str:
|
| 344 |
return (
|
| 345 |
f'<div style="background:#fef2f2;border:1.5px solid #fca5a5;border-radius:12px;'
|
| 346 |
+
f'padding:18px 20px;color:#b91c1c;font-family:sans-serif;font-size:13px;line-height:1.7;">'
|
| 347 |
+
f'{msg}</div>'
|
| 348 |
)
|
| 349 |
|
| 350 |
|
| 351 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 352 |
+
# Startup banner (shown in Space logs)
|
| 353 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 354 |
+
|
| 355 |
+
print("=" * 55)
|
| 356 |
+
print(" Amazon Trailer Inspector β starting up")
|
| 357 |
+
print(f" Token status : {TOKEN_MSG}")
|
| 358 |
+
print(f" Models : {[m.split('/')[-1] for m in MODELS]}")
|
| 359 |
+
print("=" * 55)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 363 |
# Gradio UI
|
| 364 |
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 365 |
|
| 366 |
+
TOKEN_BANNER = "" if TOKEN_OK else (
|
| 367 |
+
'<div style="background:#fef3c7;border:1.5px solid #fde68a;border-radius:10px;'
|
| 368 |
+
'padding:12px 16px;margin-bottom:14px;font-size:13px;color:#92400e;font-family:sans-serif;">'
|
| 369 |
+
'β οΈ <b>HF_TOKEN not set.</b> Go to Space <b>Settings β Repository Secrets</b> '
|
| 370 |
+
'and add <code>HF_TOKEN</code> with your HuggingFace Read token. '
|
| 371 |
+
'Get one free at <a href="https://huggingface.co/settings/tokens" target="_blank">'
|
| 372 |
+
'huggingface.co/settings/tokens</a></div>'
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
CSS = """
|
| 376 |
.gradio-container { max-width: 980px !important; margin: auto !important; }
|
|
|
|
| 377 |
#analyze-btn { font-size: 15px !important; font-weight: 700 !important;
|
| 378 |
letter-spacing: .02em; border-radius: 10px !important; }
|
| 379 |
footer { display: none !important; }
|
|
|
|
| 387 |
|
| 388 |
with gr.Blocks(title="π Amazon Trailer Inspector", theme=THEME, css=CSS) as demo:
|
| 389 |
|
| 390 |
+
gr.HTML(f"""
|
|
|
|
| 391 |
<div style="text-align:center;padding:30px 0 18px;font-family:sans-serif;">
|
| 392 |
<div style="font-size:46px;margin-bottom:10px;">π</div>
|
| 393 |
<h1 style="font-size:26px;font-weight:800;color:#0f172a;margin:0 0 6px;">
|
|
|
|
| 396 |
<p style="color:#64748b;font-size:14px;margin:0;">
|
| 397 |
AI-powered verification of required trailer components from photos
|
| 398 |
</p>
|
| 399 |
+
</div>
|
| 400 |
+
{TOKEN_BANNER}""")
|
| 401 |
|
|
|
|
| 402 |
with gr.Row(equal_height=False):
|
| 403 |
|
|
|
|
| 404 |
with gr.Column(scale=1, min_width=280):
|
|
|
|
| 405 |
gr.HTML("""
|
| 406 |
<div style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:14px;
|
| 407 |
padding:16px 18px;margin-bottom:14px;">
|
|
|
|
| 411 |
</div>
|
| 412 |
<div style="display:grid;gap:9px;font-size:13px;color:#334155;">
|
| 413 |
<div style="display:flex;align-items:center;gap:10px;">
|
| 414 |
+
<span style="background:#fef3c7;border-radius:7px;padding:4px 9px;">π·</span>
|
| 415 |
<span><b>Sensors</b> β two diamond-shaped plates</span>
|
| 416 |
</div>
|
| 417 |
<div style="display:flex;align-items:center;gap:10px;">
|
| 418 |
+
<span style="background:#dbeafe;border-radius:7px;padding:4px 9px;">π‘</span>
|
| 419 |
<span><b>GPS Device</b> β white box, top corner</span>
|
| 420 |
</div>
|
| 421 |
<div style="display:flex;align-items:center;gap:10px;">
|
| 422 |
+
<span style="background:#ede9fe;border-radius:7px;padding:4px 9px;">π΅</span>
|
| 423 |
<span><b>Prime Logo</b> β Amazon Prime mark</span>
|
| 424 |
</div>
|
| 425 |
<div style="display:flex;align-items:center;gap:10px;">
|
| 426 |
+
<span style="background:#d1fae5;border-radius:7px;padding:4px 9px;">π·οΈ</span>
|
| 427 |
<span><b>Trailer ID</b> β corner post label strip</span>
|
| 428 |
</div>
|
| 429 |
</div>
|
|
|
|
| 433 |
label="Upload Trailer Image(s)",
|
| 434 |
file_count="multiple",
|
| 435 |
file_types=["image"],
|
| 436 |
+
type="filepath",
|
|
|
|
| 437 |
)
|
| 438 |
|
| 439 |
gr.HTML("""
|
|
|
|
| 450 |
|
| 451 |
status_html = gr.HTML(_status("idle"))
|
| 452 |
|
|
|
|
| 453 |
with gr.Column(scale=1, min_width=320):
|
| 454 |
result_html = gr.HTML(_placeholder())
|
| 455 |
|
|
|
|
| 456 |
gr.HTML("""
|
| 457 |
<div style="text-align:center;padding:20px 0 10px;color:#94a3b8;
|
| 458 |
font-size:12px;font-family:sans-serif;">
|
| 459 |
+
Llama 3.2 Vision Β· Qwen2.5-VL Β· Gemma 3 |
|
| 460 |
+
Images processed in parallel | No data stored
|
| 461 |
</div>""")
|
| 462 |
|
|
|
|
| 463 |
analyze_btn.click(
|
| 464 |
fn=analyze,
|
| 465 |
inputs=[file_input],
|
| 466 |
outputs=[result_html, status_html],
|
| 467 |
)
|
| 468 |
|
|
|
|
|
|
|
| 469 |
demo.launch()
|