mlbench123 commited on
Commit
ae78024
Β·
verified Β·
1 Parent(s): 6ef905d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +160 -95
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 (Gemma first, automatic fallback)
20
  # ──────────────────────────────────────────────────────────────
21
  MODELS = [
22
- "google/gemma-3-4b-it", # Primary – Gemma 3 multimodal (free)
23
- "meta-llama/Llama-3.2-11B-Vision-Instruct", # Fallback 1
24
- "Qwen/Qwen2.5-VL-7B-Instruct", # Fallback 2
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 β€” absolutely no extra text, no markdown code fences:
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
- # Vision helpers
47
  # ──────────────────────────────────────────────────────────────
48
 
49
- def pil_to_b64(img: Image.Image, max_side: int = 1120) -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=88)
55
  return base64.b64encode(buf.getvalue()).decode()
56
 
57
 
 
 
 
 
58
  def call_model(img: Image.Image, model: str) -> dict:
59
- """One LLM call β€” raises on failure."""
60
- token = os.environ.get("HF_TOKEN")
61
- client = InferenceClient(model=model, token=token)
62
- b64 = pil_to_b64(img)
63
 
64
  messages = [{
65
  "role": "user",
66
  "content": [
67
- {"type": "image_url", "image_url": {"url": f"data:image/jpeg;base64,{b64}"}},
68
- {"type": "text", "text": DETECTION_PROMPT},
 
 
 
 
 
 
69
  ],
70
  }]
71
 
72
- resp = client.chat_completion(messages=messages, max_tokens=512, temperature=0.05)
73
- raw = resp.choices[0].message.content.strip()
 
 
 
 
 
 
 
 
 
74
 
75
- m = re.search(r'\{[\s\S]*\}', raw)
76
  if not m:
77
- raise ValueError(f"No JSON in response: {raw[:200]}")
78
- return json.loads(m.group())
 
 
 
 
79
 
80
 
81
  def analyze_one(img: Image.Image) -> tuple:
82
- """Try models in order. Returns (result_dict_or_None, model_name_or_error)."""
83
- last_err = "no models tried"
 
 
 
 
84
  for model in MODELS:
 
85
  try:
86
  result = call_model(img, model)
87
- return result, model.split("/")[-1]
88
  except Exception as e:
89
- last_err = f"{model.split('/')[-1]}: {e}"
90
- return None, last_err
 
 
 
 
 
 
 
 
 
 
 
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
- # Main pipeline function (called by Gradio)
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 (getattr(p, "name", None) or str(p))
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
- def analyze(file_paths):
145
- """
146
- Main Gradio callback.
147
- Returns: (result_html: str, status_html: str)
148
- """
149
- images = load_images(file_paths)
150
 
151
- if not images:
 
 
152
  return (
153
- _placeholder(),
154
- _status("idle"),
155
  )
156
 
 
 
 
 
157
  n = len(images)
158
- all_results, errors, models_used = [], [], set()
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
- errors.append(meta)
169
 
170
  if not all_results:
 
 
171
  return (
172
- _error("Analysis failed β€” all models returned errors.<br>"
173
- "Make sure <b>HF_TOKEN</b> is set in Space Secrets."),
 
 
 
 
 
 
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(errors)} image(s) failed</small>"
180
- if errors else "")
181
- result_h = build_cards(merged, n, model_str, warn)
182
- status_h = _status("done", n, len(all_results))
183
 
184
- return result_h, status_h
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 = "#f0fdf4" if found else "#fef2f2"
218
- rbd = "#bbf7d0" if found else "#fecaca"
219
- stc = "#15803d" if found else "#b91c1c"
220
- stx = "βœ… Found" if found else "❌ Missing"
221
- cdc = {"high":"#16a34a","medium":"#d97706","low":"#dc2626"}.get(conf,"#9ca3af")
222
- note = (
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
- {note}
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", "Analysis failed β€” check HF_TOKEN secret"),
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'padding:6px 0 2px;">{icon} {text}</div>'
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:14px;">'
292
- f'⚠️ {msg}</div>'
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
- # ── Header ────────────────────────────────────────────────
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;font-size:15px;">πŸ”·</span>
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;font-size:15px;">πŸ“‘</span>
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;font-size:15px;">πŸ”΅</span>
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;font-size:15px;">🏷️</span>
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", # HF Spaces: returns plain string paths
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
- Gemma 3 Β· Llama 3.2 Vision Β· Qwen2.5-VL &nbsp;|&nbsp;
392
- Images processed in parallel &nbsp;|&nbsp; No data is stored
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 &nbsp;|&nbsp;
460
+ Images processed in parallel &nbsp;|&nbsp; 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()