FanLR commited on
Commit
07f2e54
·
verified ·
1 Parent(s): 360e1c4

Fix responsive layout, request status lifecycle and prediction inspection

Browse files
app.py CHANGED
@@ -4,6 +4,7 @@ import base64
4
  import html
5
  import json
6
  import os
 
7
  from io import BytesIO
8
 
9
  import gradio as gr
@@ -259,7 +260,11 @@ def run_inference_stream(path, query_text, route):
259
 
260
 
261
  def run_console(path, query_text, route):
 
 
262
  for state in run_inference_stream(path, query_text, route):
 
 
263
  yield (*state, state[-1])
264
 
265
 
@@ -275,6 +280,7 @@ def service_status():
275
 
276
  CSS = (ROOT / "assets" / "paper.css").read_text(encoding="utf-8")
277
  CSS += "\n" + (ROOT / "assets" / "original.css").read_text(encoding="utf-8")
 
278
  CSS = CSS.replace(
279
  "__MANROPE_DATA__",
280
  base64.b64encode((ROOT / "assets" / "Manrope-variable.ttf").read_bytes()).decode(),
@@ -282,7 +288,7 @@ CSS = CSS.replace(
282
  # Gradio 5 scopes css= rules inside .contain, including media-query selectors.
283
  # Host sizing must live in head; workbench styling remains scoped.
284
  HOST_HEAD = """<style id="grounding-host-layout">
285
- gradio-app:has(#console) .gradio-container { width:100%!important; max-width:1800px!important; min-width:0!important; box-sizing:border-box; overflow:clip!important; padding:24px 32px 32px!important; margin:0 auto!important; }
286
  gradio-app:has(#console) .gradio-container>main { width:100%!important; max-width:none!important; min-width:0!important; padding:0!important; }
287
  gradio-app:has(#console) .gradio-container .contain { max-width:none!important; }
288
  gradio-app:has(#console) footer:not(.studio-footer) { display:none!important; }
@@ -357,10 +363,10 @@ with gr.Blocks(
357
  + html.escape(os.getenv("GROUNDING_MAX_TOKENS", "1024"))
358
  + "</b><small>Read-only runtime configuration. Only the current image and task prompt enter inference.</small></div>"
359
  )
 
360
  with gr.Column(elem_id="service-panel"):
361
  health = gr.HTML("<div class='api-status'>Checking inference service…</div>")
362
  health_btn = gr.Button("Refresh connection", size="sm", elem_id="health-button")
363
- run_btn = gr.Button(RUN_LABEL, variant="primary", elem_id="run-button")
364
  with gr.Column(scale=7, min_width=400, elem_id="visual-stage"):
365
  task_help = gr.HTML(task_help_html(first_route), elem_id="task-guide")
366
  with gr.Row(elem_id="stage-toolbar"):
@@ -374,6 +380,7 @@ with gr.Blocks(
374
  height=400,
375
  elem_id="stage-image",
376
  show_download_button=False,
 
377
  show_fullscreen_button=False,
378
  )
379
  gr.HTML(
@@ -406,6 +413,8 @@ with gr.Blocks(
406
  )
407
  gr.HTML("<p class='query-hint'>Enter to run · Shift + Enter for a new line</p>")
408
  mobile_run = gr.Button(RUN_LABEL, variant="primary", elem_id="mobile-run-button")
 
 
409
  with gr.Row(elem_id="review-row", equal_height=True):
410
  with gr.Column(scale=4, min_width=320, elem_id="sandbox-panel"):
411
  gr.HTML(
@@ -432,22 +441,20 @@ with gr.Blocks(
432
  metrics = gr.HTML(metrics_html(ROUTE_META[first_route][0]))
433
  with gr.Column(scale=6, min_width=240, elem_id="sidebar"):
434
  gr.HTML(
435
- "<div class='inspector-heading'><h2>Visual Evidence</h2><span class='live-mark'>Select a category to inspect individual instances.</span></div>"
436
  )
437
- with gr.Column(elem_id="targets-panel"):
438
- targets = gr.HTML(targets_html([]), elem_id="targets-content")
439
  gr.HTML(
440
  "<div class='evidence-detail' hidden><div class='detail-heading'><span>Detail View</span><small>Original pixels · magnified</small></div><div class='detail-image'></div><div class='detail-coordinates'></div></div>",
441
  elem_id="detail-panel",
442
  )
443
- with gr.Accordion("Prompt Console", open=True, elem_id="prompt-console"):
444
  prompt = gr.Textbox(
445
  value=first_case["prompt"], label="COMPILED PROMPT", lines=3, interactive=False
446
  )
447
  gr.HTML(
448
  "<div class='console-note'>Transparent inputs: only this image and compiled prompt are sent to the model. No annotations, training answers, or expected counts are included.</div>"
449
  )
450
- with gr.Accordion("Structured Output", open=True, elem_id="output-console"):
451
  normalized = gr.Code(
452
  value="[]", language="json", label="PARSED COORDINATES · 0–999", interactive=False
453
  )
 
4
  import html
5
  import json
6
  import os
7
+ from uuid import uuid4
8
  from io import BytesIO
9
 
10
  import gradio as gr
 
260
 
261
 
262
  def run_console(path, query_text, route):
263
+ # Identifies this request even when a proxy coalesces busy + final SSE events.
264
+ run_id = uuid4().hex
265
  for state in run_inference_stream(path, query_text, route):
266
+ state = list(state)
267
+ state[5] = state[5].replace("data-phase=", f"data-run-id='{run_id}' data-phase=", 1)
268
  yield (*state, state[-1])
269
 
270
 
 
280
 
281
  CSS = (ROOT / "assets" / "paper.css").read_text(encoding="utf-8")
282
  CSS += "\n" + (ROOT / "assets" / "original.css").read_text(encoding="utf-8")
283
+ CSS += "\n" + (ROOT / "assets" / "studio.css").read_text(encoding="utf-8")
284
  CSS = CSS.replace(
285
  "__MANROPE_DATA__",
286
  base64.b64encode((ROOT / "assets" / "Manrope-variable.ttf").read_bytes()).decode(),
 
288
  # Gradio 5 scopes css= rules inside .contain, including media-query selectors.
289
  # Host sizing must live in head; workbench styling remains scoped.
290
  HOST_HEAD = """<style id="grounding-host-layout">
291
+ gradio-app:has(#console) .gradio-container { width:100%!important; max-width:1440px!important; min-width:0!important; box-sizing:border-box; overflow:clip!important; padding:24px 32px 32px!important; margin:0 auto!important; }
292
  gradio-app:has(#console) .gradio-container>main { width:100%!important; max-width:none!important; min-width:0!important; padding:0!important; }
293
  gradio-app:has(#console) .gradio-container .contain { max-width:none!important; }
294
  gradio-app:has(#console) footer:not(.studio-footer) { display:none!important; }
 
363
  + html.escape(os.getenv("GROUNDING_MAX_TOKENS", "1024"))
364
  + "</b><small>Read-only runtime configuration. Only the current image and task prompt enter inference.</small></div>"
365
  )
366
+ run_btn = gr.Button(RUN_LABEL, variant="primary", elem_id="run-button")
367
  with gr.Column(elem_id="service-panel"):
368
  health = gr.HTML("<div class='api-status'>Checking inference service…</div>")
369
  health_btn = gr.Button("Refresh connection", size="sm", elem_id="health-button")
 
370
  with gr.Column(scale=7, min_width=400, elem_id="visual-stage"):
371
  task_help = gr.HTML(task_help_html(first_route), elem_id="task-guide")
372
  with gr.Row(elem_id="stage-toolbar"):
 
380
  height=400,
381
  elem_id="stage-image",
382
  show_download_button=False,
383
+ show_share_button=False,
384
  show_fullscreen_button=False,
385
  )
386
  gr.HTML(
 
413
  )
414
  gr.HTML("<p class='query-hint'>Enter to run · Shift + Enter for a new line</p>")
415
  mobile_run = gr.Button(RUN_LABEL, variant="primary", elem_id="mobile-run-button")
416
+ with gr.Column(elem_id="targets-panel"):
417
+ targets = gr.HTML(targets_html([]), elem_id="targets-content")
418
  with gr.Row(elem_id="review-row", equal_height=True):
419
  with gr.Column(scale=4, min_width=320, elem_id="sandbox-panel"):
420
  gr.HTML(
 
441
  metrics = gr.HTML(metrics_html(ROUTE_META[first_route][0]))
442
  with gr.Column(scale=6, min_width=240, elem_id="sidebar"):
443
  gr.HTML(
444
+ "<div class='inspector-heading'><h2>Target Detail</h2><span class='live-mark'>Select a category below the canvas to magnify its original pixels.</span></div>"
445
  )
 
 
446
  gr.HTML(
447
  "<div class='evidence-detail' hidden><div class='detail-heading'><span>Detail View</span><small>Original pixels · magnified</small></div><div class='detail-image'></div><div class='detail-coordinates'></div></div>",
448
  elem_id="detail-panel",
449
  )
450
+ with gr.Accordion("Prompt Console", open=False, elem_id="prompt-console"):
451
  prompt = gr.Textbox(
452
  value=first_case["prompt"], label="COMPILED PROMPT", lines=3, interactive=False
453
  )
454
  gr.HTML(
455
  "<div class='console-note'>Transparent inputs: only this image and compiled prompt are sent to the model. No annotations, training answers, or expected counts are included.</div>"
456
  )
457
+ with gr.Accordion("Structured Output", open=False, elem_id="output-console"):
458
  normalized = gr.Code(
459
  value="[]", language="json", label="PARSED COORDINATES · 0–999", interactive=False
460
  )
assets/console.js CHANGED
@@ -6,6 +6,7 @@
6
  const listen = (el, event, fn) => el.addEventListener(event, fn, { signal: abort.signal });
7
  let observer, resize, frame, interval, selected, targetRoot, image, lastSrc, lastDraw;
8
  let waiting = false, waitStart = 0, ready = false, watchdog, instanceIndex = 0;
 
9
  let zoom = 1, panX = 0, panY = 0, drag, moved = false;
10
  const $ = s => document.querySelector(s);
11
  const svg = (tag, attrs = {}) => {
@@ -94,7 +95,9 @@
94
  : { cx: p[0] / 999 * w, cy: p[1] / 999 * h, r: 13 };
95
  const tag = p.length === 4 ? 'rect' : 'circle';
96
  mask.append(svg(tag, { ...attr, fill: 'black' }));
97
- marks.push(svg(tag, { ...attr, class: 'focus-outline', pathLength: 1 }));
 
 
98
  }
99
  defs.append(mask); layer.append(defs);
100
  layer.append(svg('rect', { width: w, height: h, mask: 'url(#grounding-focus-mask)', class: 'focus-dimmer' }), ...marks);
@@ -153,12 +156,19 @@
153
  const clock = document.createElement('time'); clock.className = 'wait-clock';
154
  clock.setAttribute('aria-hidden', 'true'); clock.textContent = '0.0 s'; node.append(clock);
155
  }
156
- $('#stage-status .stage-status')?.replaceWith(node);
 
 
 
 
 
157
  }
158
  // A reverse proxy can buffer the first SSE event. Acknowledge the actual click
159
  // immediately; backend events still own final results and measured latency.
160
  window.groundingConsoleBegin = () => {
161
  if (!ready || waiting) return;
 
 
162
  clientStatus('Request submitted · Awaiting inference', 'busy'); setWaiting(true);
163
  watchdog = setTimeout(() => {
164
  if (!waiting) return;
@@ -173,7 +183,13 @@
173
  function reconcile() {
174
  const stage = $('#stage-image'), targets = $('#targets-content');
175
  if (!stage || !targets) return;
176
- setWaiting($('.stage-status')?.dataset.phase === 'busy');
 
 
 
 
 
 
177
  const chips = targets.querySelector('.target-chips');
178
  if (targetRoot !== chips) { targetRoot = chips; clearSelection(); }
179
  const nextImage = stage.querySelector('img');
@@ -269,6 +285,7 @@
269
  window.groundingConsoleCleanup = () => {
270
  abort.abort(); observer?.disconnect(); resize?.disconnect(); cancelAnimationFrame(frame); clearInterval(interval); clearTimeout(watchdog);
271
  if (ready) setWaiting(false); removeLayer();
 
272
  delete window.groundingConsoleBegin;
273
  };
274
  }
 
6
  const listen = (el, event, fn) => el.addEventListener(event, fn, { signal: abort.signal });
7
  let observer, resize, frame, interval, selected, targetRoot, image, lastSrc, lastDraw;
8
  let waiting = false, waitStart = 0, ready = false, watchdog, instanceIndex = 0;
9
+ let clientPending = false, previousRunId = '', clientNotice;
10
  let zoom = 1, panX = 0, panY = 0, drag, moved = false;
11
  const $ = s => document.querySelector(s);
12
  const svg = (tag, attrs = {}) => {
 
95
  : { cx: p[0] / 999 * w, cy: p[1] / 999 * h, r: 13 };
96
  const tag = p.length === 4 ? 'rect' : 'circle';
97
  mask.append(svg(tag, { ...attr, fill: 'black' }));
98
+ const outline = svg(tag, { ...attr, class: 'focus-outline', pathLength: 1 });
99
+ outline.style.stroke = getComputedStyle(selected.querySelector('i')).backgroundColor;
100
+ marks.push(svg(tag, { ...attr, class: 'focus-halo' }), outline);
101
  }
102
  defs.append(mask); layer.append(defs);
103
  layer.append(svg('rect', { width: w, height: h, mask: 'url(#grounding-focus-mask)', class: 'focus-dimmer' }), ...marks);
 
156
  const clock = document.createElement('time'); clock.className = 'wait-clock';
157
  clock.setAttribute('aria-hidden', 'true'); clock.textContent = '0.0 s'; node.append(clock);
158
  }
159
+ // This node belongs to us, outside Gradio's HTML component. Never replace
160
+ // Svelte-owned children: doing so can leave an orphan busy status forever.
161
+ clientNotice?.remove(); clientNotice = node;
162
+ node.classList.add('client-notice');
163
+ $('#stage-toolbar').append(node);
164
+ $('#stage-toolbar').classList.add('client-pending');
165
  }
166
  // A reverse proxy can buffer the first SSE event. Acknowledge the actual click
167
  // immediately; backend events still own final results and measured latency.
168
  window.groundingConsoleBegin = () => {
169
  if (!ready || waiting) return;
170
+ previousRunId = $('#stage-status .stage-status')?.dataset.runId || '';
171
+ clientPending = true;
172
  clientStatus('Request submitted · Awaiting inference', 'busy'); setWaiting(true);
173
  watchdog = setTimeout(() => {
174
  if (!waiting) return;
 
183
  function reconcile() {
184
  const stage = $('#stage-image'), targets = $('#targets-content');
185
  if (!stage || !targets) return;
186
+ const server = $('#stage-status .stage-status');
187
+ if (clientPending && (server?.dataset.runId || '') !== previousRunId) {
188
+ clientPending = false;
189
+ clientNotice?.remove(); clientNotice = null;
190
+ $('#stage-toolbar').classList.remove('client-pending');
191
+ }
192
+ if (!clientPending) setWaiting(server?.dataset.phase === 'busy');
193
  const chips = targets.querySelector('.target-chips');
194
  if (targetRoot !== chips) { targetRoot = chips; clearSelection(); }
195
  const nextImage = stage.querySelector('img');
 
285
  window.groundingConsoleCleanup = () => {
286
  abort.abort(); observer?.disconnect(); resize?.disconnect(); cancelAnimationFrame(frame); clearInterval(interval); clearTimeout(watchdog);
287
  if (ready) setWaiting(false); removeLayer();
288
+ clientNotice?.remove(); $('#stage-toolbar')?.classList.remove('client-pending');
289
  delete window.groundingConsoleBegin;
290
  };
291
  }
assets/studio.css ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* GroundingPI studio r8. Shared by the standalone demo and private HF Space. */
2
+ #workspace-top {min-height:0;box-shadow:0 12px 36px #30204408;background:#fff}
3
+ #controls {flex:0 0 286px!important;min-width:0!important;width:286px;padding:24px!important;gap:20px!important;background:#fcfbfe}
4
+ .control-brand {container-type:inline-size;gap:12px}
5
+ .control-brand h2 {font-size:clamp(26px,14cqw,38px);white-space:nowrap;line-height:1.15;letter-spacing:-.045em}
6
+ .control-brand .xpeng-logo {width:176px;max-width:80%;height:auto}
7
+ .control-brand p {font-size:12px;line-height:1.65}
8
+ #control-settings {padding:12px!important;gap:12px!important}
9
+ #control-fields {flex-direction:column!important;gap:10px!important}
10
+ #control-fields>.form {width:100%!important;flex:0 0 auto!important}
11
+ #media-type {flex:0 0 auto!important}
12
+ .media-field {display:flex;align-items:center;justify-content:space-between;gap:12px}
13
+ .media-field>span {margin:0!important}.media-field>div {height:28px;background:none;border:0;padding:0}
14
+ .media-field>div>span {min-width:0;background:#edf3e2;color:#4c681c;box-shadow:none;font-size:11px;padding:0 10px}
15
+ #run-button {margin-top:0!important;min-height:48px!important;border-radius:8px!important;box-shadow:none!important;font-size:14px!important}
16
+ #service-panel {gap:2px!important;margin-top:0!important}
17
+ .api-status small {font-size:11px}
18
+ #service-panel #health-button {min-height:32px!important;justify-content:flex-start;padding-left:0!important}
19
+ #controls[inert] {opacity:1}
20
+ #visual-stage {--stage-gutter:24px;background:#fff}
21
+ #task-guide {padding:22px var(--stage-gutter) 0!important}
22
+ .task-guide strong {font-size:18px}.task-guide p {font-size:12px}
23
+ .task-guide small {font-size:11px}
24
+ #stage-toolbar {min-height:40px;padding:10px var(--stage-gutter)!important;gap:0!important}
25
+ .stage-status {padding:0;border:0;background:transparent;font-size:11px;min-height:20px;align-items:center;flex-wrap:nowrap}
26
+ .stage-status i {margin-top:0!important}.stage-status.busy i {margin-top:0!important}
27
+ .stage-status .wait-clock {white-space:nowrap;margin-left:8px}
28
+ #stage-toolbar.client-pending #stage-status {display:none!important}
29
+ .client-notice {max-width:100%;flex:1}
30
+ #canvas-viewport,#canvas-viewport:has(.focus-caption) {padding:0 var(--stage-gutter)!important;overflow:hidden!important}
31
+ #stage-image {height:clamp(300px,48vh,520px)!important;overflow:visible!important;transition:transform 180ms ease}
32
+ #stage-image .image-container {background:#f6f5f8!important;border-radius:6px;overflow:hidden}
33
+ #stage-image img {box-shadow:none;border-radius:0}
34
+ #stage-image .icon-button-wrapper {display:none!important}
35
+ #canvas-chrome {position:relative!important;inset:auto!important;flex:0 0 auto!important;background:#fff}
36
+ .canvas-tools {padding:8px 0;min-height:48px;gap:8px}
37
+ .image-dimensions {border:0;background:transparent;padding:0;color:var(--muted)}
38
+ .zoom-tools {border-color:#e8e4ec;border-radius:6px;box-shadow:none}
39
+ .zoom-tools button,.canvas-download {width:32px;height:32px}
40
+ .zoom-value {min-width:42px}
41
+ .focus-halo {fill:none;stroke:#fff;stroke-width:5;vector-effect:non-scaling-stroke;opacity:.95}
42
+ .focus-outline {fill:none;stroke-width:2.5;vector-effect:non-scaling-stroke;animation:none}
43
+ .focus-dimmer {opacity:.16;animation:none}
44
+ .focus-caption {width:100%!important;max-width:100%;box-sizing:border-box;margin:0 0 10px;padding:6px 8px 6px 12px;box-shadow:none;background:#f8f6fb;gap:6px}
45
+ .focus-caption span {max-height:none;font-size:12px;flex:1}
46
+ .focus-caption button {width:32px;height:32px;flex-shrink:0}
47
+ #query-row {padding:6px var(--stage-gutter) 14px!important}
48
+ #query-dock {max-width:none;width:100%;padding:10px 12px!important;box-shadow:none!important;border-color:#ddd6e6!important}
49
+ #query-dock textarea {font-size:12px!important;line-height:1.7!important}
50
+ .query-hint {text-align:left;margin:7px 0 0;font-size:10px}
51
+ #targets-panel {padding:0 var(--stage-gutter) 20px!important;gap:0!important;border:0!important;min-width:0!important}
52
+ .targets-summary {border-top:1px solid var(--line);padding-top:14px;font-size:12px}
53
+ .targets-summary b {font-size:16px}.targets-summary>span {font-size:11px}
54
+ .target-chips {display:flex!important;flex-direction:row;flex-wrap:wrap;max-height:140px!important;gap:6px;margin:10px 0 0;padding:2px}
55
+ .target-chip {width:auto!important;flex:0 1 auto;min-width:0;min-height:34px!important;padding:7px 10px!important;gap:7px!important;background:#fff!important;border-radius:6px!important;font-size:11px!important}
56
+ .target-chip>span {overflow-wrap:anywhere}.target-chip b {margin-left:3px}
57
+ .target-chip[aria-pressed=true] {border-color:var(--purple)!important;background:#f0eaf7!important}
58
+ .target-chip:focus-visible {outline:2px solid var(--purple);outline-offset:2px}
59
+ .focus-help {margin-top:10px;gap:12px}.targets-caption {display:none}
60
+ .empty-targets {min-height:0;padding:12px 0;border-top:1px solid var(--line);text-align:left;align-items:flex-start}
61
+ .empty-targets strong {font-size:12px}.empty-targets span {font-size:11px}.empty-targets svg {display:none}
62
+ #review-row {gap:16px!important}
63
+ #sandbox-panel {flex-basis:40%!important;box-shadow:none;padding:18px!important}
64
+ #review-summary {min-width:0!important;box-shadow:none}
65
+ #result-columns {gap:16px!important}
66
+ #metrics-panel {min-width:0!important;flex:1 1 45%!important}
67
+ #sidebar {min-width:0!important;flex:1 1 55%!important}
68
+ #sidebar:not(:has(.evidence-detail:not([hidden]))) {display:none!important}
69
+ #result-columns:not(:has(.evidence-detail:not([hidden]))) .metrics-list {display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px 24px}
70
+ .detail-image {height:170px}.evidence-detail {max-width:none}
71
+ #prompt-console,#output-console {box-shadow:none!important;padding:12px 18px!important}
72
+ .studio-header {padding-bottom:18px}.studio-header h1 {font-size:30px}
73
+ @media(min-width:1500px){#controls {flex-basis:310px!important;width:310px;padding:28px!important}.control-brand .xpeng-logo {width:190px}#visual-stage {--stage-gutter:32px}#stage-image {height:clamp(420px,50vh,580px)!important}}
74
+ @media(max-width:1100px){#controls {flex-basis:246px!important;width:246px;padding:18px!important}.control-brand h2 {font-size:14cqw}#visual-stage {--stage-gutter:18px}#stage-image {height:clamp(300px,40vw,440px)!important}.header-scope {display:none}#review-row {flex-wrap:wrap!important}#sandbox-panel {flex:1 1 34%!important}#review-summary {flex:1 1 58%!important}}
75
+ @media(max-width:760px){
76
+ .studio-header h1 {font-size:25px}.header-description {font-size:12px}
77
+ #controls {width:100%;flex:0 0 auto!important;padding:16px!important;gap:12px!important}
78
+ .control-brand {container-type:normal;gap:10px;flex-wrap:nowrap}
79
+ .control-brand h2 {font-size:clamp(22px,6vw,30px);white-space:nowrap}
80
+ .control-brand .xpeng-logo {width:96px;max-width:30%}
81
+ #control-settings {padding:0!important;border:0!important;gap:10px!important}
82
+ #control-fields {flex-direction:row!important;align-items:center;gap:16px!important}
83
+ #control-fields>.form {width:0!important;flex:1 1 0!important}
84
+ #media-type {flex:0 0 30%!important}.media-field {display:block}.media-field>span {margin-bottom:7px!important}
85
+ .media-field>div {height:39px}.media-field>div>span {min-width:60px}
86
+ #service-panel {padding-top:6px!important}.api-status small,.connection-detail {display:none}
87
+ #service-panel #health-button {min-height:28px!important;font-size:11px!important}
88
+ #visual-stage {--stage-gutter:14px}
89
+ #task-guide {padding-top:16px!important}.task-guide strong {font-size:16px}
90
+ #stage-image {height:clamp(230px,68vw,440px)!important}
91
+ #stage-toolbar {padding-top:10px!important;padding-bottom:10px!important}
92
+ .canvas-tools {flex-wrap:wrap}.zoom-tools button,.canvas-download {width:38px;height:38px}
93
+ #query-row {padding-top:4px!important}#mobile-run-button {border-radius:8px!important;font-size:14px!important;margin-top:10px}
94
+ #targets-panel {padding-bottom:16px!important}.target-chip {min-height:40px!important}.target-chips {max-height:none!important}
95
+ .focus-caption button {width:38px;height:38px}.focus-caption span {font-size:11px}
96
+ #review-row {flex-direction:column!important}#sandbox-panel,#review-summary {flex:0 0 auto!important}
97
+ #metrics-panel,#sidebar {flex:0 0 auto!important}
98
+ }
docs/UI_STUDIO_R8.md ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GroundingPI Studio r8
2
+
3
+ User-requested layout and visualization revision, 2026-09-16. The same UI files
4
+ serve the standalone demo and the private Hugging Face Space.
5
+
6
+ ## Changes
7
+
8
+ - Container-relative brand sizing prevents the GroundingPI title from clipping.
9
+ - Bounded sidebar, consistent canvas/query gutters, visible desktop action, and
10
+ stacked mobile controls. Native image sharing is explicitly disabled.
11
+ - Request identifiers let the browser distinguish consecutive identical outputs.
12
+ The immediate client acknowledgement owns a separate node; it never replaces
13
+ Gradio/Svelte-managed status DOM. Completion/error clears the timer and inert state.
14
+ - Category chips sit next to the canvas workflow. Selection shows original pixels,
15
+ exact returned geometry, category-colored focus with a contrast halo, instance
16
+ navigation and a magnified crop. Closing focus restores every prediction.
17
+ - Dense boxes use fine edges and short corner marks, with no filled interiors.
18
+ No prediction is suppressed, merged, moved, deduplicated or replaced.
19
+ - Empty detail panels stay hidden; raw prompt/output remain available in accordions.
20
+
21
+ ## Contract
22
+
23
+ The 27 examples, 9 task routes, protocol parser, prompts, native inference backend,
24
+ model revision and generated coordinates are unchanged. Dataset annotations are
25
+ never substituted for predictions. The original r7 manifest remains as provenance;
26
+ `ui_studio_r8_lock.json` records the reviewed new UI bytes and unchanged case contract.
27
+
28
+ ## Verification scope
29
+
30
+ Console request lifecycle, protocol/render regressions and packaging lock checks;
31
+ browser review at mobile, intermediate and desktop widths; success, error and repeat
32
+ requests; category/instance focus, zoom/reset, example switching and upload reset.
33
+ Local visual regression uses a separate, non-deployed HTTP fixture replaying a
34
+ recorded real 27-target HF response. Its latency is test latency. The deployed HF
35
+ Space is checked with a fresh real model request after release; these checks are
36
+ qualitative UI acceptance, not a benchmark.
docs/ui_studio_r8_lock.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": {
3
+ "app.py": "95f3f0d399588edf789fecdd7099ef4622f180cdb7fe8389989bf0951a89a78f",
4
+ "assets/Manrope-OFL.txt": "e01b637272e0cbdfb240184dd98ea5cc671556d9894dae2668d92ab2c906787c",
5
+ "assets/Manrope-variable.ttf": "d0639be45d0af36e798172419d7bd173c4bd4f29e2b76cbb69db1d11bf8b0a40",
6
+ "assets/SOURCES.md": "3cceb52136b55f86840e9585e3c37faed1adc762452b0fea50aecba696dac7fa",
7
+ "assets/console.js": "3af36a3aeafb72e85018b1c663f624b92877bad0152ee9e264a1c440d689f60e",
8
+ "assets/frosted.css": "393bf5f1c04a49a6e9b17e855e573646489b4add85bc7973eb6bb277686b5c80",
9
+ "assets/original.css": "7a244c7f65e40797850dcde7d6b6505b1b5de60aa24b129f44b33ae52291fd2e",
10
+ "assets/paper.css": "61fc60bf8c379a0894fbfe06574964ec2ed256791a23860fa4dbb872eb2869f9",
11
+ "assets/studio.css": "3275fe0212672640f477e4273a6c8052648f361c7631400aaf6ad2aaebc89185",
12
+ "assets/xpeng-logo.png": "ff0db78ddf8f9a122eec74e8eb79581f8e5dbc46803dddeb780a06f5ae295750",
13
+ "cases/dense/dense-01-o365_bbox_gt50_00009110.jpg": "398038106cbac731516dd01de25354fe4d61f3599df64baa611f1d5a99e0e8fb",
14
+ "cases/dense/dense-02-o365_bbox_gt50_00004676.jpg": "7ee3beb63bdfd50c183538bd56f9d7e079fa41bde78aad9d5fe0d22c041c2673",
15
+ "cases/dense/dense-03-o365_bbox_gt50_00011992.jpg": "eb9331146b66a1af1e8c832a22043c6f1b45d5d3bb7231816f975ced2cd88e39",
16
+ "cases/dense_point/dense_point-01-sensenova_sa1b_point_1067906__phrase_000.jpg": "917975c9b31791290a2a660c91af8ae0653cfe258fa0c53539bcb868867aece2",
17
+ "cases/dense_point/dense_point-02-sensenova_sa1b_point_1445784__phrase_002.jpg": "0b9a2281fec580c02b9292d8b3f96b675a8659d721f4dd8a1ae16a1eadbaf4e9",
18
+ "cases/dense_point/dense_point-03-sensenova_sa1b_point_1451598__phrase_001.jpg": "d2f0e070f49cc001ef6855964175c8c383358fcc2185d62abf29ba0de0d1be22",
19
+ "cases/grounding/grounding-01-object365-locate_anything_object365_object365-file-0027-row-000000119924.jpg": "2e3946eafcbdc2d2ac9e0b62343595e0b51e71b38eb15c3b0669fab46c78e9f6",
20
+ "cases/grounding/grounding-02-object365-locate_anything_object365_object365-file-0027-row-000001469030.jpg": "c6abef94a28290ca94e8dd043ba58e326cfcb22088b75f32274acc07af15950c",
21
+ "cases/grounding/grounding-03-object365-locate_anything_object365_object365-file-0027-row-000001076761.jpg": "ff9581327870a05ad8679c3e5891f27dfe55acc98ed713a8abf8d02e7604774b",
22
+ "cases/gui/gui-01-groundcua-locate_anything_groundcua_groundcua-file-0014-row-000000043686__gui_0008.jpg": "c9c27a9df49bac1c9664eb74ecf1fe9967e20281add73fff1273437af06b743b",
23
+ "cases/gui/gui-02-groundcua-locate_anything_groundcua_groundcua-file-0014-row-000000006009__gui_0028.jpg": "7ff12fde621319e2959b3112fa6c1fe4e6ef83b8943a6ee169d7fc2140f59e74",
24
+ "cases/gui/gui-03-groundcua-locate_anything_groundcua_groundcua-file-0014-row-000000041853__gui_0052.jpg": "8ba06f95a90cf6954aa7c8f7554b4f28d966afbfc8b8151c96e540e224139cf5",
25
+ "cases/layout/layout-01-layout_doclaynet_train_16966.jpg": "8ba09e49907c5d7a3a31148a3fceaf2695bd04f35a017a62ef8c455b985f7218",
26
+ "cases/layout/layout-02-layout_doclaynet_train_20356.jpg": "5fbf03f82cc66566e4630547b53b7f51cd0ebd772a072c1d6f37f8c0bafdb646",
27
+ "cases/layout/layout-03-layout_doclaynet_train_3435.jpg": "8e035b83d1ca53298816550d4891fd9f2d84f83b59bba178cafdf6bf8623a9fe",
28
+ "cases/ocr/ocr-01-ocr_textocr-locate_anything_ocr_textocr_textocr-file-0042-row-000000019189.jpg": "4634583edfab147e8708c6119e726fb8f394744e0225c8319136bb5000ba5d8c",
29
+ "cases/ocr/ocr-02-ocr_textocr-locate_anything_ocr_textocr_textocr-file-0042-row-000000018671.jpg": "8db936cadff524299c5b61b94992f3b70ec90e1dd3873f072b34d41692393bdd",
30
+ "cases/ocr/ocr-03-ocr_textocr-locate_anything_ocr_textocr_textocr-file-0042-row-000000021130.jpg": "d959da84d676117701e6d3920bff692aab94d6075e5714e2a324df3b61f18cf8",
31
+ "cases/refer_point/refer_point-01-roboafford_affordance_point_paco_491580_000.jpg": "e5233bcfadb4d6cb3be1a0ac75573183084494661dd31cc42e7282d21b6c3e13",
32
+ "cases/refer_point/refer_point-02-roboafford_affordance_point_paco_558444_002.jpg": "df60dcc4812e93720202874e7b88cca122b25ff2615dd31e2bd697a552bce322",
33
+ "cases/refer_point/refer_point-03-roboafford_affordance_point_paco_311488_000.jpg": "e4fc006bbcb49c5e57c3b002a35e5866c15c898d6930117e0f89617121cd3928",
34
+ "cases/referring/referring-01-humanref_nocot_lf2_00007963.jpg": "4fac0cc45af121260bc7468aa63fbe7f6dfdcbc5a2f251c04a8a0e06212048df",
35
+ "cases/referring/referring-02-humanref_nocot_lf2_00004121.jpg": "9b7ec8dc3ad013a57fab9d1199ee0ce14a3104f8e56f60df908b8769d32e195d",
36
+ "cases/referring/referring-03-humanref_nocot_lf2_00011458.jpg": "44978de1893cdcf9c0c3ed7dd40df3d3f38b7c5334ba0929d2604a2a2fb5d561",
37
+ "cases/visual_prompt/visual_prompt-01-sensenova_sa1b_visual_1718306.jpg": "57104d07c82cfb97a72075ef479f7d8d1c00db82386aa5c38da0ef163a82dc2f",
38
+ "cases/visual_prompt/visual_prompt-02-sensenova_sa1b_visual_2475968.jpg": "ba96beb1cc54682f56e34b1381fefc9b7b95905363cff917bbfdac886e265bac",
39
+ "cases/visual_prompt/visual_prompt-03-sensenova_sa1b_visual_1573415.jpg": "2593b4f81c4fbfd70e73df0710fb143af232126daa0a96bc86c72575713475a5",
40
+ "grounding_demo/catalog.py": "75e543fed7f0ca018fb41c4b528efc2901aa6a0f05232959297d6d40798f676f",
41
+ "grounding_demo/console.py": "dbc1a08c937f18d5c104d4ab18316937d21a2b0d2df9ba2cba296d9ae1e20f16",
42
+ "grounding_demo/protocol.py": "aa4e634070ab7a17c6bc477051e97cb02e772e9e37db89d5de9b6497312635b0",
43
+ "grounding_demo/render.py": "a4d43398a7d42a2f7884b16141bd43be3b687fdd7ce24fa89405341fb6152b21"
44
+ },
45
+ "case_contract_sha256": "d00f2091dcb3c866e5e3a331f89537839640b2374c4bc32685960706d18b58b1",
46
+ "cases": 27,
47
+ "routes": 9
48
+ }
grounding_demo/render.py CHANGED
@@ -48,7 +48,11 @@ def draw_primitives(
48
  overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
49
  draw = ImageDraw.Draw(overlay)
50
  width, height = canvas.size
51
- line = max(2, round(min(width, height) / 260))
 
 
 
 
52
  radius = max(6, line * 3)
53
  font = _font(max(13, round(min(width, height) / 42)))
54
  label_colors: dict[str, tuple[int, int, int]] = {}
@@ -65,7 +69,6 @@ def draw_primitives(
65
  draw.line((x, y1, min(x + dash, x2), y1), fill=(*GREEN, 255), width=line)
66
  draw.line((x, y2, min(x + dash, x2), y2), fill=(*GREEN, 255), width=line)
67
 
68
- primitives = list(primitives)
69
  labels = []
70
  # Opaque RGBA writes replace earlier pixels in a Pillow layer. In particular,
71
  # a later lake/cloud fill used to erase every small object drawn inside it.
@@ -86,6 +89,11 @@ def draw_primitives(
86
  else:
87
  x1, y1, x2, y2 = coords
88
  draw.rectangle(coords, outline=(*color, 255), width=line)
 
 
 
 
 
89
  if seen[primitive.label] == 1:
90
  labels.append((primitive.label, color, coords))
91
 
 
48
  overlay = Image.new("RGBA", canvas.size, (0, 0, 0, 0))
49
  draw = ImageDraw.Draw(overlay)
50
  width, height = canvas.size
51
+ primitives = list(primitives)
52
+ dense = len(primitives) > 12
53
+ # Fine boundaries keep a dense scene readable; short corners make small
54
+ # boxes discoverable without filling, moving, or removing any prediction.
55
+ line = max(1 if dense else 2, round(min(width, height) / (420 if dense else 260)))
56
  radius = max(6, line * 3)
57
  font = _font(max(13, round(min(width, height) / 42)))
58
  label_colors: dict[str, tuple[int, int, int]] = {}
 
69
  draw.line((x, y1, min(x + dash, x2), y1), fill=(*GREEN, 255), width=line)
70
  draw.line((x, y2, min(x + dash, x2), y2), fill=(*GREEN, 255), width=line)
71
 
 
72
  labels = []
73
  # Opaque RGBA writes replace earlier pixels in a Pillow layer. In particular,
74
  # a later lake/cloud fill used to erase every small object drawn inside it.
 
89
  else:
90
  x1, y1, x2, y2 = coords
91
  draw.rectangle(coords, outline=(*color, 255), width=line)
92
+ if dense:
93
+ corner = min(max(4, round(min(width, height) / 80)), (x2-x1)//3, (y2-y1)//3)
94
+ if corner > 0:
95
+ for x, y, dx, dy in ((x1,y1,1,1),(x2,y1,-1,1),(x1,y2,1,-1),(x2,y2,-1,-1)):
96
+ draw.line((x+dx*corner,y,x,y,x,y+dy*corner), fill=(*color,255), width=max(2,line))
97
  if seen[primitive.label] == 1:
98
  labels.append((primitive.label, color, coords))
99
 
scripts/build_bundle.py CHANGED
@@ -17,7 +17,9 @@ def main():
17
  if args.output.exists():
18
  raise SystemExit("Output already exists; choose a new archive path.")
19
  args.output.parent.mkdir(parents=True, exist_ok=True)
20
- lock = ROOT / "docs/ui_paper_r7_lock.json"
 
 
21
  if not lock.exists():
22
  lock = ROOT / "docs/ui_lock.json"
23
  if fingerprint(ROOT) != json.loads(lock.read_text()):
@@ -33,6 +35,8 @@ def main():
33
  "scripts/prepare_model_repo.py", "scripts/validate_gradio.py"]
34
  paths = [ROOT / name for name in top]
35
  paths.extend([lock, ROOT / "docs/UI_PAPER_R7.md"])
 
 
36
  for folder, suffixes in {"assets": {".css", ".js", ".png", ".ttf", ".txt", ".md"},
37
  "grounding_demo": {".py"}, "tests": {".py"}}.items():
38
  paths.extend(p for p in (ROOT / folder).rglob("*") if p.is_file() and p.suffix in suffixes)
 
17
  if args.output.exists():
18
  raise SystemExit("Output already exists; choose a new archive path.")
19
  args.output.parent.mkdir(parents=True, exist_ok=True)
20
+ lock = ROOT / "docs/ui_studio_r8_lock.json"
21
+ if not lock.exists():
22
+ lock = ROOT / "docs/ui_paper_r7_lock.json"
23
  if not lock.exists():
24
  lock = ROOT / "docs/ui_lock.json"
25
  if fingerprint(ROOT) != json.loads(lock.read_text()):
 
35
  "scripts/prepare_model_repo.py", "scripts/validate_gradio.py"]
36
  paths = [ROOT / name for name in top]
37
  paths.extend([lock, ROOT / "docs/UI_PAPER_R7.md"])
38
+ if (ROOT / "docs/UI_STUDIO_R8.md").exists():
39
+ paths.append(ROOT / "docs/UI_STUDIO_R8.md")
40
  for folder, suffixes in {"assets": {".css", ".js", ".png", ".ttf", ".txt", ".md"},
41
  "grounding_demo": {".py"}, "tests": {".py"}}.items():
42
  paths.extend(p for p in (ROOT / folder).rglob("*") if p.is_file() and p.suffix in suffixes)
scripts/verify_ui.py CHANGED
@@ -29,7 +29,9 @@ def main():
29
  parser.add_argument("--reference", type=Path, help="Original project; compare then create lock without overwrite")
30
  args = parser.parse_args()
31
  current = fingerprint(ROOT)
32
- lock = ROOT / "docs/ui_paper_r7_lock.json"
 
 
33
  if not lock.exists():
34
  lock = ROOT / "docs/ui_lock.json"
35
  if args.reference:
 
29
  parser.add_argument("--reference", type=Path, help="Original project; compare then create lock without overwrite")
30
  args = parser.parse_args()
31
  current = fingerprint(ROOT)
32
+ lock = ROOT / "docs/ui_studio_r8_lock.json"
33
+ if not lock.exists():
34
+ lock = ROOT / "docs/ui_paper_r7_lock.json"
35
  if not lock.exists():
36
  lock = ROOT / "docs/ui_lock.json"
37
  if args.reference:
tests/test_console.py CHANGED
@@ -1,6 +1,7 @@
1
  import json
2
  import html
3
  import unittest
 
4
  from html.parser import HTMLParser
5
  from unittest.mock import patch
6
 
@@ -9,6 +10,22 @@ from grounding_demo.console import compile_query, query_from_prompt, targets_htm
9
 
10
 
11
  class ConsoleTest(unittest.TestCase):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  def test_all_27_prompts_roundtrip_exactly(self):
13
  for row in app.CASES:
14
  with self.subTest(case=row["id"]):
 
1
  import json
2
  import html
3
  import unittest
4
+ import re
5
  from html.parser import HTMLParser
6
  from unittest.mock import patch
7
 
 
10
 
11
 
12
  class ConsoleTest(unittest.TestCase):
13
+ def test_request_ids_distinguish_identical_results_and_errors(self):
14
+ path = str(app.ROOT / app.CASES[0]["image"])
15
+ raw = "<|object_ref_start|>car<|object_ref_end|><|box_start|><1><2><30><40><|box_end|>"
16
+ runs = []
17
+ for error in (False, False, True):
18
+ with patch.object(app.GroundingClient, "infer", return_value=(raw, .1, {}),
19
+ side_effect=TimeoutError("test") if error else None):
20
+ states = list(app.run_console(path, "car", "grounding"))
21
+ ids = [re.search("data-run-id='([^']+)'", state[5]).group(1) for state in states]
22
+ self.assertEqual(ids[0], ids[-1])
23
+ self.assertIn("data-phase='busy'", states[0][5])
24
+ self.assertNotIn("data-phase='busy'", states[-1][5])
25
+ self.assertTrue(states[-1][-1]["interactive"])
26
+ runs.append(ids[0])
27
+ self.assertEqual(len(set(runs)), 3)
28
+
29
  def test_all_27_prompts_roundtrip_exactly(self):
30
  for row in app.CASES:
31
  with self.subTest(case=row["id"]):