Skip to content

Instantly share code, notes, and snippets.

@simonw
Created August 15, 2026 21:30
Show Gist options
  • Select an option

  • Save simonw/121ad098860028b2fab603fa12da1fd9 to your computer and use it in GitHub Desktop.

Select an option

Save simonw/121ad098860028b2fab603fa12da1fd9 to your computer and use it in GitHub Desktop.

2026-08-15T20:33:00 conversation: 01m03j1e764f80a2fhyjfj0grg id: 01m03j1e78k2ayq63npg59vje3

Model: lmstudio/qwen/qwen3.8-27b (resolved: qwen/qwen3.8-27b)

Prompt

[ {"bbox_2d": [195, 290, 370, 780], "label": "pelicans"}, {"bbox_2d": [445, 320, 675, 850], "label": "pelicans"} ]

Build an HTML page which has an input box for accepting the URL to an image and a textarea for accepting the above style of JSON.

It appends the image to the page, measures its width and height, then treats the coords in the bbox_2d as scaled from 0-1000 and scales them against the actual width and height, then it renders labelled boxes over the image.

Prompt fragments

  • 27d2a64f79421d26b215452d5a2647579d0c203c344451ba213b700c3508cdaa

Reasoning

The user is asking for an HTML page like the following:

  1. An input box for the image URL
  2. A textarea for JSON containing bounding boxes (bbox_2d format: [x1, y1, x2, y2] on a 0–1000 scale)
  3. When applied, append the image to the page, measure its width and height
  4. Scale the coordinates from 0–1000 to the actual width/height
  5. Render labeled boxes over the image

So this is a tool for visualizing bounding boxes. I want to make it polished and crafted, with a tool-like character — like a lab/annotation workbench. Since the subject is an image annotation inspector, I should start with the tool itself: a workbench layout with controls on one side and the canvas/stage on the other.

Design direction:

  • Theme: "bounding box inspector" — an annotation lab. Give it a technical, precise, drafting-tool feel. A deep ink-blue/slate scheme with multiple accents (amber for box 1, cyan for box 2, green, etc.) — color-coding boxes by index.
  • Typography: distinctive display face + readable body. For display "Space Grotesk" or "Archivo" + "IBM Plex Mono" for data, body "IBM Plex Sans"? Or a technical feel: display "Chakra Petch" (techy, angular) + body "IBM Plex Sans" + mono "IBM Plex Mono". This fits an annotation/ML tool. Google Fonts: Chakra Petch, IBM Plex Sans, IBM Plex Mono.

Palette: deep slate/ink background but with multiple accents. Or a "blueprint" theme: dark blue-gray (around #0f1420?) with grid lines, multiple accent colors. Or light theme: paper white with graph-paper grid, ink text, vivid box colors. A graph-paper annotation sheet feels very distinctive to the subject (boxes over images). Hmm, but dark looks good with glowing boxes.

Let's go with a deep "lab" background: deep blue-slate #101622 / #131b2a, subtle grid pattern (like a measurement grid), boxes with per-index accent colors: amber #ffb454, cyan #4fd6e0, magenta #ff6b9d, green #7ee787… Text is a warm off-white.

Actually, to be more distinctive: a "photogrammetry/annotation console." Left panel: controls (URL input, JSON textarea, apply button, parsed detections list). Right: stage where the image is displayed with overlay boxes, rulers/tick marks, coordinate readout on hover.

Dynamic touches:

  • Background: faint grid + subtle animated scanlines or drifting dots? Keep performance. Subtle grid + soft radial glows in two colors (amber top-left, cyan bottom-right) — but that veers toward generic aurora, so skip it. Use a graph-paper grid + faint noise instead.
  • Hovering a box: highlight, show tooltip with scaled coordinates.
  • Hovering a row in the detection list highlights the corresponding box.
  • Apply: boxes draw themselves with an animation (stroke-dashoffset or scale-in).
  • Live stats: image WxH, count, scale factor.
  • Status line with a blinking cursor? A "READY" indicator.

Also: prefill the textarea with the given JSON so the user only needs to paste an image URL? The user provided the JSON as "the above style" — so the textarea is for accepting that style. Preloading the sample JSON is a thoughtful touch. Do it — prefill with the given JSON.

Also a "load sample" that uses a known image? Can't depend on external images, but… the image URL input is user-provided; I could add a "try with sample" button that loads e.g. https://images.unsplash.com/… but network-dependent; CORS? For drawing boxes, I just need a displayable image — I'll use a plain , so no CORS issue. But hotlinking to unsplash may be unreliable. Better: offer a "use sample image" that generates a canvas-based placeholder image (data URL) with a pelican silhouette? Hmm, I can draw a simple scene on canvas, export it as a data URL, and load it into the image — that's self-contained and demo-able! Actually this is a great touch: a "demo scene" button that generates a 1000x1000 (or 800x600) canvas with shapes placed at the given bbox positions, so the boxes visibly line up. But the user's coords are for an actual pelican image; a generated placeholder can still demo the scaling. Generate a 1000x1000 placeholder: gradient water + two blob-like "pelican" silhouettes placed at the given bboxes (using the same scale — cute: silhouettes at the exact 0-1000 positions, showing the boxes align). This makes for a fun, self-contained demo. Keep it simple: sky gradient, sun, water, two pelican-ish shapes (ellipse body, circle head, beak). Place at bbox centers.

Core logic:

  • On "Apply" (or on input? better an explicit "Render" button + Enter key):
    • Validate JSON: an array of objects with bbox_2d [x1,y1,x2,y2] and optional label.
    • Create/replace stage: set the img's src to the URL, on load measure naturalWidth/naturalHeight. Display: constrain to stage via CSS max-width/max-height; measured width = img.clientWidth/clientHeight after layout (rendered size) — since the overlay is positioned relative to the displayed size, scaling should be against the displayed size. Simpler and more robust: wrap the image in a container sized to the image's rendered size (position:relative), overlay divs absolutely positioned with percentages? The request: "measure the width and height, and treat the bbox_2d coordinates as being on a 0–1000 scale, and scale them to the actual width and height." So compute px = x/1000 * actualWidth. Set px width/height on the overlay boxes. If the image is responsive and resizes on window resize, I should recompute. I'll listen to window resize and re-render the overlay.
    • Display actual width/height in the stats.

Edge cases: URL empty, JSON parse error → show inline status message (amber for error, green for success).

Colors per detection: cycle through a palette.

Box rendering: absolute div with 2px border, semi-transparent fill, label tag top-left (offset above the box), corner ticks (like an annotation tool) — nice touch: 4 corner brackets + thin border. Tooltip on hover with scaled coords: "x: 195→78px …" Show "x,y,w,h (px)".

Also crosshair following the mouse over the image with live 0–1000 coordinate display? This is a great tool-like touch: a readout showing the mouse position in both 0–1000 space and px. Adds a live feel and fits the purpose. Let's do it: on mousemove over the stage, show a small floating readout near the cursor or in the stage's status bar: "x: 412 / y: 655 (0–1000)" + "364px / 310px". Also draw crosshair lines? Maybe just the readout to keep it clean. Crosshair lines are cool: 2 thin lines absolutely positioned following the cursor. Add it, subtle.

Layout:

  • Header: compact, tool-style: small logomark (bracket glyph), title "BBOX / inspector" or "FRAME — bounding box inspector". Keep it: "bbox·lab" or "FRAME LAB". Let me call it "bbox lab — coordinate visualizer." Left-aligned header bar with a version tag, status LED on the right (READY / RENDERING / ERROR).
  • Main: 2 columns (responsive stack). Left column: "Input" card: URL field, JSON textarea (monospace), buttons: [Render boxes] [Load demo scene] [Clear]. Below: detection list (auto-generated from parsed JSON with color chips, label, raw coords, scaled coords after rendering). Right: stage: image + overlay, stage header strip with filename, dimensions "W × H", scale factor "1 unit = 0.78 px", count. Empty state: dashed frame with corner brackets and a hint text ("awaiting image — paste a URL and hit Render").

Micro-interactions:

  • Button hover: shift/lift, accent underline.
  • Box hover: fill opacity up, label brightens, list row highlights.
  • List row hover: box pulses.
  • Render animation: boxes scale in / stroke draw (transition from scale 0.9 + opacity), staggered.
  • Background: fixed grid (repeating-linear-gradients) + subtle vignette; faint animated "scan" line moving vertically very slowly? Maybe a subtly drifting noise or a slow-moving highlight on the grid. Keep it restrained: an animated scanline at low opacity across the stage area, or a pulse of the status LED. I'll do a subtle scan sweep across the whole stage when rendering, plus the pulsing status dot.

Fonts:

  • Display: "Chakra Petch" (600/700) — techy.
  • Body: "IBM Plex Sans".
  • Mono: "IBM Plex Mono" for coordinates/JSON. Load via Google Fonts link.

Palette:

  • bg: #0d1220 (deep slate navy) — with multiple accent colors (amber, cyan, magenta, lime) and layered panels of slightly different tones, plus grid.
  • Panels: #151c2e / #1a2338, border #2a3550.
  • Text: #e8ecf6, muted #8b96ad.
  • Accents: amber #ffb454 (primary action), cyan #53d8e8, magenta #ff7ab8, lime #9fe870, violet #b48cff for detection colors.
  • Status: ready = #9fe870 green, error = #ff6b6b.

This is a multi-accent scheme, fine.

Corner radius: small (4-6px) — technical feel.

Header: distinctive opening — not a generic hero, but a tool masthead: left: bracket logo "[⌖]" + name "bbox lab" in display font, tagline "0–1000 coordinate → pixel overlay". Right: status LED + "ready".

Now code structure: single HTML file, embedded CSS + JS. Should work from file:// — external fonts OK.

Key implementation details:

const PALETTE = ['#ffb454','#53d8e8','#ff7ab8','#9fe870','#b48cff','#ffd166'];

function applyRender(){
  const url = urlInput.value.trim();
  if(!url) return setStatus('error','No image URL');
  let dets;
  try { dets = JSON.parse(jsonInput.value); } catch(e){ return setStatus('error','JSON parse error: '+e.message); }
  if(!Array.isArray(dets) || !dets.length) return setStatus('error','Expected a non-empty array');
  // validate
  dets = dets.map((d,i)=>{
     const b=d.bbox_2d;
     if(!Array.isArray(b)||b.length!==4||b.some(n=>typeof n!=='number')) throw ...
     return {...d, label: d.label ?? ('box '+(i+1)), idx:i};
  });
  // load image
  const img = new Image();
  img.decoding='async';
  img.onload = ()=> render(img, dets, img.naturalWidth, img.naturalHeight);
  img.onerror = ()=> setStatus('error','Could not load image');
  img.src = url;
}

Rendering: stage contains a #frame (position:relative, inline-block). Set img src (reuse one img element). After load and layout, compute rect = img.getBoundingClientRect() or clientWidth/Height. Create overlay divs.

Scale: sx = w/1000, sy = h/1000. For each det: left = x1/1000w, top = y1/1000h, width = (x2-x1)/1000w, height=(y2-y1)/1000h.

Re-render on resize (debounced) — keep the last image + dets.

List rows: color chip, label, raw "195,290 → 370,780", scaled "x 78 y 112 w 70 h 196 px" (only after render).

Crosshair: mousemove over frame → position two lines + readout in stage footer. Convert to 0-1000: x1000 = mx/w*1000.

Demo scene: canvas 1000x600? But the boxes have y up to 850, x up to 675 — use 1000x1000? The aspect ratio of the pelican image is unknown; use 1000x750? y=850 exceeds 750. Use 1000x1000 canvas. Draw: sky gradient top, water bottom, sun, two pelican silhouettes placed at the given bboxes: det0 center ~ (282, 535), det1 ~ (560, 585). Draw simple pelicans: ellipse body, neck curve, head, big beak. A simple dark silhouette with an amber beak. Good enough for a demo. Then set url input to the canvas.toDataURL(), and call applyRender().

Actually even simpler: just set the urlInput value and call applyRender — a consistent code path.

Also a "load sample data" button to restore the sample JSON? I prefill the textarea with the given JSON. Keep it.

Status LED: .led with a class ready/working/error, pulsing animation.

Stage empty state: dashed border box with corner marks and text. Hide once rendered.

Scanline animation on render: an overlay div that sweeps top→bottom once (CSS animation, removed after).

Box label: position label at top:-?; place the label inside the box top-left, with a background of the color, dark text. Also corner brackets: use ::before/::after? Need 4 corners — use 4 span elements .c.c1..c4, or 2 pseudos + a background gradient. Simpler: box has border 1.5px + corner ticks via 4 absolutely positioned spans (10x10, border-top/left etc). I'll generate spans.

Tooltip on box hover: .tip element with px coords + raw values, shown on hover (CSS). Position above the box; ensure overflow visible.

Accessibility: labels on inputs, aria-live on status.

Responsive: grid-template-columns: minmax(320px, 420px) 1fr; stack under 900px.

Textarea: monospace, min-height 220px, resize vertical.

Buttons: primary (amber fill, dark text), ghost (outline). Hover: translateY(-1px), glow shadow.

Small footer: "coords interpreted on 0–1000 normalized grid · rendered against intrinsic dimensions".

Also show scale: "1 unit ≈ 0.78px" per axis if aspect differs.

Edge cases: image with max-width constraint — displayed w might be smaller than natural. The request says "measure the width and height" — ambiguous: natural or rendered. The overlay needs to match the rendered size for placement. If I compute px from naturalWidth but the image is displayed smaller, boxes will be misplaced. Solution: compute the scale from the current rendered (displayed) size — which is what the measurement of the displayed image gives. But I'll report both natural and displayed. To keep it faithful and simple: set img to natural size? No — better to constrain: img {max-width:100%; height:auto}. Then use img.clientWidth/clientHeight as "the actual width and height." This is the measurement of the image on the page. Good, and it re-renders on resize. Show "displayed 512×640 · natural 1000×1000" in stats.

Hmm — but to be faithful to "scale to actual width and height," use displayed. Show both. Fine.

Now let me write the code carefully.

Structure:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>bbox·lab — 0–1000 coordinate inspector</title>
<link fonts...>
<style>...</style>
</head>
<body>
<div class="bg-grid"></div> (fixed, behind)
<header class="masthead">
  <div class="brand">
    <span class="glyph">[⌖]</span>  // maybe custom svg
    <h1>bbox<span>·lab</span></h1>
    <span class="tagline">0–1000 grid → pixels, over any image</span>
  </div>
  <div class="status"><span class="led" id="led"></span><span id="statusText">READY</span></div>
</header>

<main class="workbench">
  <section class="panel controls">
    <div class="panel-head"><h2>01 · input</h2><span class="hint">URL + detections</span></div>
    <label for="url">image url</label>
    <input id="url" ... placeholder="https://…/pelicans.jpg">
    <label>detections (json)</label>
    <textarea id="json" spellcheck="false">[sample]</textarea>
    <div class="actions">
      <button id="renderBtn" class="btn primary">Render boxes</button>
      <button id="demoBtn" class="btn ghost">Demo scene</button>
      <button id="clearBtn" class="btn ghost">Clear</button>
    </div>
    <div class="inline-status" id="inlineStatus" aria-live="polite"></div>
    <div class="panel-head"><h2>02 · detections</h2><span id="detCount"></span></div>
    <ul id="detList" class="det-list"></ul>
  </section>

  <section class="panel stage-panel">
    <div class="panel-head stage-head">
      <h2>03 · stage</h2>
      <div class="stats" id="stats"></div>
    </div>
    <div class="stage" id="stage">
      <div class="empty" id="empty">
        corner marks + "awaiting image" text + tip
      </div>
      <div class="frame hidden" id="frame">
        <img id="img" alt="loaded image">
        <div id="boxes"></div>
        <div class="ch-v"></div><div class="ch-h"></div>
        <div class="scan"></div>
      </div>
    </div>
    <div class="stage-foot">
      <span id="coordReadout">x —  y —</span>
      <span class="foot-note">coords read on the 0–1000 grid</span>
    </div>
  </section>
</main>

<footer></footer>
<script></script>
</body></html>

CSS details:

Body background: #0c111d; layered:

background:
 radial-gradient(1000px 500px at 85% -10%, rgba(83,216,232,.06), transparent 60%),
 radial-gradient(800px 500px at -10% 110%, rgba(255,180,84,.05), transparent 60%),
 #0c111d;

Subtle corner glows, not blobs — very low opacity, acceptable and subtle. Plus grid overlay:

.bg-grid{position:fixed;inset:0;pointer-events:none;
 background:
  linear-gradient(rgba(140,160,210,.05) 1px, transparent 1px),
  linear-gradient(90deg, rgba(140,160,210,.05) 1px, transparent 1px);
 background-size: 32px 32px;
 mask-image: radial-gradient(...)? maybe none.
}

Panels: background: #131a2b; border:1px solid #26314e; border-radius:8px; box-shadow: 0 10px 30px rgba(0,0,0,.35). Panel-head: flex, small-caps, letter-spacing, display font, border-bottom.

Type scale: h1 28px display 700; h2 13px uppercase tracking .12em; body 14.5px.

Buttons:

.btn{font-family:Chakra Petch; letter-spacing:.06em; text-transform:uppercase; font-size:12.5px; padding:10px 16px; border-radius:5px; border:1px solid; cursor:pointer; transition: transform .15s, box-shadow .2s, background .2s;}
.btn.primary{background:var(--amber); color:#1a1206; border-color:#ffc879;}
.btn.primary:hover{transform:translateY(-2px); box-shadow:0 6px 18px rgba(255,180,84,.35);}
.btn.ghost{background:transparent; color:#9fb0d0; border-color:#33415f;}
.btn.ghost:hover{color:#e8ecf6; border-color:#53d8e8; box-shadow:0 0 0 1px rgba(83,216,232,.2) inset;}

Inputs:

#url, #json {width:100%; background:#0b101d; border:1px solid #2a3550; color:#e8ecf6; border-radius:6px; padding:10px 12px; font: 13px IBM Plex Mono; transition: border-color, box-shadow;}
:focus {outline:none; border-color:var(--amber); box-shadow:0 0 0 3px rgba(255,180,84,.15);}
textarea{min-height:230px; resize:vertical; line-height:1.55; tab-size:2;}

Detection list rows:

.det{display:grid; grid-template-columns: 14px 1fr auto; gap:10px; align-items:start; padding:10px 8px; border:1px solid transparent; border-radius:6px; cursor:pointer; transition: background .15s, border-color .15s;}
.det:hover{background:rgba(140,160,210,.06); border-color:#2a3550;}
.chip{width:12px;height:12px;border-radius:3px; margin-top:3px; box-shadow:0 0 8px currentColor;}
.det .raw{font:11px mono; color:#8b96ad;}
.det .scaled{font:11px mono; color:var(--c);}
.det.hot {background:rgba(...); border-color: var(--c);} // when hovering the box

Box overlay:

.box{position:absolute; border:1.5px solid var(--c); background: color-mix(in srgb, var(--c) 14%, transparent); border-radius:2px; cursor:pointer; transition: background .2s, box-shadow .2s, transform .3s, opacity .3s;}
.box .tick{position:absolute; width:10px;height:10px; border:2px solid var(--c); }
.t1{top:-2px;left:-2px; border-right:0;border-bottom:0;}
...
.box .tag{position:absolute; left:-2px; top:-2px; transform:translateY(-100%); background:var(--c); color:#10141f; font: 600 11px Chakra Petch; padding:2px 8px 2px 6px; letter-spacing:.04em; white-space:nowrap; border-radius:3px 3px 0 0;}
.box .tip{position:absolute; left:0; top:100%; margin-top:6px; background:#0b101d; border:1px solid var(--c); padding:6px 8px; font:11px mono; color:#cfd8ee; white-space:nowrap; opacity:0; pointer-events:none; transform:translateY(4px); transition:.2s; z-index:5; border-radius:4px;}
.box:hover .tip{opacity:1; transform:none;}
.box:hover{background: color-mix(... 26%); box-shadow:0 0 0 1px var(--c), 0 0 18px color-mix(in srgb, var(--c) 45%, transparent);}

color-mix is widely supported now; fine.

Entrance animation:

@keyframes boxIn { from {opacity:0; transform:scale(.85);} to {opacity:1; transform:scale(1);} }
.box{ animation: boxIn .45s cubic-bezier(.2,.9,.3,1.4) backwards; }

Set animation-delay = i*90ms per box.

Scan:

.scan{position:absolute; left:0; right:0; height:2px; top:-5%; background:linear-gradient(90deg, transparent, rgba(83,216,232,.8), transparent); box-shadow:0 0 18px rgba(83,216,232,.6); opacity:0; pointer-events:none;}
.scan.go{animation: sweep 1.1s ease .1s 1;}
@keyframes sweep{0%{top:-2%;opacity:0}10%{opacity:.9}90%{opacity:.9}100%{top:102%;opacity:0}}

Crosshairs:

.ch-v,.ch-h{position:absolute; background:rgba(83,216,232,.35); pointer-events:none; opacity:0; transition:opacity .15s;}
.ch-v{top:0;bottom:0;width:1px;}
.ch-h{left:0;right:0;height:1px;}
.frame.tracking .ch-v,.frame.tracking .ch-h{opacity:1;}

Position set via style.left/top.

Stage:

.stage{position:relative; display:flex; align-items:center; justify-content:center; min-height:420px; padding:26px; background:
 repeating-conic-gradient? no — checkerboard for transparency:
 background:
  linear-gradient(45deg, #0e1424 25%, transparent 25%) 0 0/24px 24px, ... standard checker with 2 colors #0f1526/#111a2e;
 border:1px solid #22304d; border-radius:6px; overflow:hidden;
}

Checkerboard:

background-color:#101829;
background-image:
 linear-gradient(45deg,#151f34 25%,transparent 25%,transparent 75%,#151f34 75%),
 linear-gradient(45deg,#151f34 25%,transparent 25%,transparent 75%,#151f34 75%);
background-size:28px 28px;
background-position:0 0,14px 14px;

Frame: position:relative; line-height:0; box-shadow: 0 12px 40px rgba(0,0,0,.5); border:1px solid #2c3a5c.

img: display:block; max-width:100%; height:auto; max-height:72vh.

Empty state:

.empty{position:absolute; inset:0; display:flex; flex-direction:column; gap:10px; align-items:center; justify-content:center; color:#5f6d8c; pointer-events:none;}
.empty .corners{...} use a div with 4 corner spans sized 260x160 with borders.

Simpler: a div .frameghost{width:min(320px,60%); aspect-ratio:16/10; border:1.5px dashed #33415f; position:relative;} with 4 corner spans (14px) in amber. Plus text.

Status LED:

.led{width:9px;height:9px;border-radius:50%; background:#3a4763; display:inline-block; margin-right:8px;}
.led.ready{background:var(--lime); box-shadow:0 0 10px var(--lime); animation:pulse 2.2s infinite;}
.led.work{background:var(--amber); ...}
.led.error{background:#ff6b6b; ...}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.45}}

Stats: monospace, small, colored segments.

coordReadout: monospace; update on mousemove: x 412 y 655 · 364 × 310 px.

Now the JS:

const $ = s => document.querySelector(s);
const urlIn=$('#url'), jsonIn=$('#json'), ...

const PALETTE=['#ffb454','#53d8e8','#ff7ab8','#9fe870','#b48cff','#ffd166','#7ea8ff'];

let state={imgSrc:null, dets:[], natW:0, natH:0};

const SAMPLE = `[
  {"bbox_2d": [195, 290, 370, 780], "label": "pelicans"},
  {"bbox_2d": [445, 320, 675, 850], "label": "pelicans"}
]`;
jsonIn.value = SAMPLE;

function setStatus(kind, msg){ led.className='led '+kind; statusText.textContent = kind.toUpperCase() + (msg? ' · '+msg : ''); inline... }

Actually keep both: header LED state + inline message below actions.

function parseDetections(raw){
  let data; try{ data=JSON.parse(raw);}catch(e){ throw new Error('JSON: '+e.message); }
  if(!Array.isArray(data)) throw new Error('expected a JSON array');
  if(!data.length) throw new Error('array is empty');
  return data.map((d,i)=>{
    const b=d.bbox_2d;
    if(!b||!Array.isArray(b)||b.length!==4) throw new Error(`item ${i}: bbox_2d must be [x1,y1,x2,y2]`);
    const [x1,y1,x2,y2]=b;
    if(b.some(n=>typeof n!=='number'||!isFinite(n))) throw new Error(`item ${i}: bbox_2d values must be numbers`);
    const label=(d.label!=null&&String(d.label).trim())?String(d.label).trim():('box '+(i+1));
    return {label, x1,y1,x2,y2, color:PALETTE[i%PALETTE.length]};
  });
}

function render(){
  const url=urlIn.value.trim();
  if(!url){return fail('paste an image URL first');}
  let dets; try{ dets=parseDetections(jsonIn.value);}catch(e){return fail(e.message);}
  setLED('work','LOADING');
  const img=new Image();
  img.onload=()=>{
    state={imgSrc:url, dets, natW:img.naturalWidth, natH:img.naturalHeight};
    elImg.src=url;
    frame.classList.remove('hidden'); empty.classList.add('hidden');
    layout(dets);
    buildList(dets);
    setLED('ready',`RENDERED ${dets.length} BOX${dets.length>1?'ES':''}`);
    ok('ok');
    scan();
  };
  img.onerror=()=>{ setLED('error','IMAGE 404 / CORS'); fail('could not load that image URL'); };
  img.src=url;
}

function layout(dets){
  const w=elImg.clientWidth||elImg.naturalWidth;
  const h=elImg.clientHeight||img.naturalHeight;
  boxes.innerHTML='';
  dets.forEach((d,i)=>{
    const left=d.x1/1000*w, top=d.y1/1000*h, bw=(d.x2-d.x1)/1000*w, bh=(d.y2-d.y1)/1000*h;
    const el=document.createElement('div'); el.className='box';
    el.style.cssText=`left:${left}px;top:${top}px;width:${bw}px;height:${bh}px;--c:${d.color};animation-delay:${i*90}ms`;
    el.dataset.i=i;
    el.innerHTML = `<span class="tag">${esc(d.label)}</span>
      <i class="tick t1"></i><i class="tick t2"></i><i class="tick t3"></i><i class="tick t4"></i>
      <span class="tip">${esc(d.label)} · x ${Math.round(left)} y ${Math.round(top)} · ${Math.round(bw)}×${Math.round(bh)} px<br>raw [${d.x1}, ${d.y1}, ${d.x2}, ${d.y2}]</span>`;
    el.addEventListener('mouseenter',()=>row(i,true));
    el.addEventListener('mouseleave',()=>row(i,false));
    boxes.appendChild(el);
  });
  stats.innerHTML = ... `w ${w} · h ${h} px <span>·</span> 1 unit = ${(w/1000).toFixed(2)}×${(h/1000).toFixed(2)} px <span>·</span> nat ${state.natW}×${state.natH}`;
}

Note: clientWidth is measured after the img has a src and is loaded; make sure to call it after the image element has loaded and is displayed — use requestAnimationFrame after setting src. Also the image's max-height 72vh might clamp the height; width auto. Fine.

Resize: window 'resize' → if state.dets, layout(state.dets) with a debounce; don't re-animate (the animation only runs when the element is created — recreating would re-trigger; acceptable, or add a .noanim class). Recreate but skip the delay/animation after the first? Simpler: on resize re-layout with no animation: add a parameter animate=true; on resize pass false → set el.style.animation='none'.

List:

function buildList(dets){
  detList.innerHTML='';
  const w=..., h=...
  dets.forEach((d,i)=>{
    const li=document.createElement('li'); li.className='det'; li.dataset.i=i; li.style.setProperty('--c',d.color);
    li.innerHTML=`<span class="chip" style="background:${d.color};color:${d.color}"></span>
      <div class="det-main"><span class="det-label">${esc(d.label)}</span>
      <span class="raw">[${d.x1}, ${d.y1}, ${d.x2}, ${d.y2}]</span></div>
      <div class="det-px">x ${...} </div>`;
    hover events  highlight box: boxes.children[i].classList.add('hot')
    click  flash box (add .flash, remove after 900ms)
    detList.appendChild(li);
  });
  detCount.textContent = dets.length + ' detected';
}

Crosshair:

frame.addEventListener('mousemove',e=>{
  const r=elImg.getBoundingClientRect();
  const x=e.clientX-r.left, y=e.clientY-r.top;
  chV.style.left=x+'px'; chH.style.top=y+'px';
  frame.classList.add('tracking');
  coordReadout.textContent = `x ${(x/r.width*1000).toFixed(0).padStart(4,'0')}  y ${(y/r.height*1000).toFixed(0).padStart(4,'0')}   ·   ${Math.round(x)} × ${Math.round(y)} px`;
});
frame.addEventListener('mouseleave',()=>{frame.classList.remove('tracking'); coordReadout.textContent='x ——  y ——';});

Clear: reset everything: hide frame, show empty, clear boxes/list/stats, LED ready 'STANDBY', clear url too? Keep url; just clear the render.

Demo scene:

function demo(){
  const c=document.createElement('canvas'); c.width=1000;c.height=1000;
  const g=c.getContext('2d');
  // sky
  const sky=g.createLinearGradient(0,0,0,620); sky.addColorStop(0,'#243a5e'); sky.addColorStop(1,'#c96f4a');
  g.fillStyle=sky; g.fillRect(0,0,1000,620);
  // sun
  g.fillStyle='rgba(255,210,150,.9)'; g.beginPath(); g.arc(760,470,70,0,7); g.fill();
  // water
  const wat=g.createLinearGradient(0,620,0,1000); wat.addColorStop(0,'#2e4a6b'); wat.addColorStop(1,'#16283f');
  g.fillStyle=wat; g.fillRect(0,620,1000,380);
  // ripples
  g.strokeStyle='rgba(200,220,255,.12)';
  for(let i=0;i<24;i++){ const y=650+Math.random()*330; g.beginPath(); g.moveTo(Math.random()*200,y); g.lineTo(1000-Math.random()*200,y); g.stroke(); }
  // pelicans at sample bboxes
  const p=(x1,y1,x2,y2)=>{ const cx=(x1+x2)/2, cy=(y1+y2)/2, s=(y2-y1);
    g.fillStyle='#101820';
    // body
    g.beginPath(); g.ellipse(cx,cy+ s*0.12, s*0.28, s*0.2,0,0,7); g.fill();
    // neck
    g.strokeStyle='#101820'; g.lineWidth=s*0.09; g.beginPath(); g.moveTo(cx+s*0.1, cy+s*0.05); g.quadraticCurveTo(cx+s*0.24, cy-s*0.12, cx+s*0.16, cy-s*0.26); g.stroke();
    // head
    g.beginPath(); g.arc(cx+s*0.16,cy-s*0.3,s*0.07,0,7); g.fill();
    // beak
    g.fillStyle='#ffb454'; g.beginPath(); g.moveTo(cx+s*0.2,cy-s*0.32); g.lineTo(cx+s*0.42,cy-s*0.22); g.lineTo(cx+s*0.2,cy-s*0.24); g.closePath(); g.fill();
    // reflection
    g.fillStyle='rgba(16,24,32,.35)'; g.beginPath(); g.ellipse(cx, cy+s*0.42, s*0.26, s*0.08,0,0,7); g.fill();
  };
  p(195,290,370,780); p(445,320,675,850);
  urlIn.value = c.toDataURL('image/png');
  render();
}

This is self-contained and fun — the silhouettes roughly sit inside the boxes.

Enter key: urlIn Enter → render; json Ctrl+Enter → render.

esc helper:

const esc=s=>String(s).replace(/[&<>"']/g,m=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));

Debounce:

let rT; window.addEventListener('resize',()=>{ if(!state.dets.length) return; clearTimeout(rT); rT=setTimeout(()=>layout(state.dets,false),150);});

layout(dets, animate=true).

Also, initial layout call: after elImg.src is set and loaded — but elImg is a different element from the loader. After setting elImg.src (which is probably cached), wait for elImg load? Safer: use the loader img dimensions directly? Simpler: set elImg.src=url, and in the loader's onload do requestAnimationFrame(()=>layout...) — the browser should have the same image in memory; the img element will size by natural dimensions after decoding. To be safe, also listen once to elImg 'load' → layout. Do this:

elImg.src=url;
const doLayout=()=>requestAnimationFrame(()=>layout(state.dets,true));
if(elImg.complete && elImg.naturalWidth) doLayout(); else elImg.addEventListener('load',doLayout,{once:true});

But elImg 'load' may have already fired. Handle both.

Actually the simplest and most robust: use the loader image element directly as the display img? The loader is created each time… I can reuse elImg as the loader: set elImg.src and listen to load. But elImg is hidden initially (frame hidden) — display:none makes clientWidth 0! The frame is hidden until load. If I display first then measure: set frame visible (display block), and once load fires and layout runs, clientWidth is valid. Order: elImg.src=url; elImg.onload → show frame, rAF, layout. The frame's visibility toggle happens in the same tick before rAF, so clientWidth should compute.

But if the image is cached and loads synchronously? Setting src then reading onload in the same script: the load event fires later (asynchronously), and my handler is assigned before — OK.

Edge case: same URL re-rendering — src doesn't change, so load event doesn't fire again. Workaround: check elImg.complete. Handle:

function showLoaded(){
  empty.classList.add('hidden'); frame.classList.remove('hidden');
  requestAnimationFrame(()=>{ layout(state.dets,true); buildList(state.dets); ... });
}
elImg.onload = showLoaded;
if (url === elImg.getAttribute('src') && elImg.complete) { elImg.onload=null; showLoaded(); } else elImg.src=url;

Hmm, careful: setting onload before src; if src is the same, no new load → check complete. Let me implement carefully:

function render(){
  ...validate...
  setLED('work','LOADING');
  const finish = (err)=>{ ... };
  if(!elImg.src || elImg.src !== url) {
     const tmp=new Image();
     tmp.onload=()=>{ elImg.src=url; requestAnimationFrame(()=>afterLoad()); };
     tmp.onerror=()=>fail('could not load image (check URL / CORS)');
     tmp.src=url;
  } else {
    requestAnimationFrame(()=>afterLoad());
  }
}

Since we cache the URL and keep the same elImg, re-rendering with the same URL works via the complete check. For a new URL, we preflight with a temp Image (catches 404 without breaking the current stage). Good.

afterLoad():

function afterLoad(){
  frame.classList.remove('hidden'); empty.classList.add('hidden');
  layout(state.dets,true);
  buildList(state.dets);
  setLED('ready', `RENDERED · ${state.dets.length} box${state.dets.length===1?'':'es'}`);
  msg('ok','boxes rendered over image');
  const s=$('.scan'); s.classList.remove('go'); void s.offsetWidth; s.classList.add('go');
}

buildList needs w/h — recompute inside from elImg.clientWidth.

Hot-sync between list and box:

function syncHot(i,on){ const b=boxes.children[i]; const l=detList.children[i]; if(b)b.classList.toggle('hot',on); if(l)l.classList.toggle('hot',on); }

Box .hot: same as hover styles.

Click on a row → box flash: add .flash, remove after 900ms.

OK, small footer: "coordinates are read on a normalized 0–1000 grid and mapped to the image's displayed pixel size · no data leaves this page".

Header brand: logo — a small SVG: a square with corner brackets + a dot. Inline SVG:

<svg width="30" height="30" viewBox="0 0 30 30"><path d="M2 9 V2 H9 M21 2 H28 V9 M28 21 V28 H21 M9 28 H2 V21" stroke="#ffb454" stroke-width="2.4" fill="none"/><circle cx="15" cy="15" r="4.5" fill="#53d8e8"/></svg>

Nice.

Title: "bbox·lab". Tagline: "normalized 0–1000 coords → pixel overlays".

Status LED: .led with a class ready/working/error, pulse animation.

Stage empty state: dashed border box with corner marks and text. Hide once rendered.

Scanline animation on render: an overlay div that sweeps top→bottom once (CSS animation, removed after).

Let me think about the masthead layout: brand on the left (logo + name + tagline), status on the right. Below the header a thin rule with tick marks? Add a decorative "ruler" strip: a horizontal strip with repeating tick marks (a background gradient) below the header — very distinctive to the measurement theme.

.ruler{height:14px; border-bottom:1px solid #22304d; background:
 linear-gradient(90deg, #33415f 0 1px, transparent 1px) left bottom/12px 7px repeat-x,
 linear-gradient(90deg, #33415f 0 1px, transparent 1px) left bottom/60px 11px repeat-x; opacity:.5;}

Hmm, background-repeat with sizes: two layers, repeat-x, positions bottom. Ticks: short every 12px, tall every 60px.

Grid background + a slow drifting "scan" band across the page? A low-opacity vertical band moving horizontally over 18s, opacity .04. Subtle:

body::after{content:""; position:fixed; inset:0; pointer-events:none; background:linear-gradient(105deg, transparent 45%, rgba(120,160,255,.05) 50%, transparent 55%); background-size:300% 100%; animation: drift 14s linear infinite;}
@keyframes drift{from{background-position:120% 0}to{background-position:-80% 0}}

OK subtle.

prefers-reduced-motion: disable animations.

Panel-head style:

.panel-head{display:flex; align-items:baseline; justify-content:space-between; padding:12px 16px; border-bottom:1px solid #22304d;}
.panel-head h2{font:600 12px 'Chakra Petch'; letter-spacing:.18em; text-transform:uppercase; color:#8b96ad;}
.panel-head h2 b{color:var(--amber); font-weight:700; margin-right:6px;}

Numbered sections 01/02/03 — gives it a tool/step character.

Labels:

label{display:block; font:600 11px 'Chakra Petch'; letter-spacing:.14em; text-transform:uppercase; color:#7f8db0; margin:16px 0 7px;}

Message area:

.msg{margin:12px 4px 0; font:12px 'IBM Plex Mono'; min-height:18px; color:#8b96ad;}
.msg.err{color:#ff8a8a;} .msg.ok{color:var(--lime);}

Buttons row: flex gap 10px, margin-top 14px.

det-list: max-height with overflow? Let it grow; panel is scrollable if needed. Fine.

Grid layout:

.workbench{display:grid; grid-template-columns: minmax(340px,430px) minmax(0,1fr); gap:18px; padding:20px; max-width:1440px; margin:0 auto; align-items:start;}
@media(max-width:920px){.workbench{grid-template-columns:1fr;}}

Stats in stage-head:

.stats{font:12px 'IBM Plex Mono'; color:#9fb0d0;}
.stats b{color:var(--cyan); font-weight:600;}

stage-foot:

.stage-foot{display:flex; justify-content:space-between; padding:10px 16px; border-top:1px solid #22304d; font:11.5px 'IBM Plex Mono'; color:#7f8db0;}

Box label placement: label above the box may clip at the top edge of the image (box 1 has y=290 which is fine; if a box starts at y=0 the label goes off). Use overflow visible on the frame — but the stage is overflow hidden; the label might clip at the stage padding (26px) — acceptable. Alternative: put label inside the box top-left with a solid background. Inside is safer: .tag{left:-1px; top:-1px;} no translate. But it covers the box contents… For an annotation tool, inside-top-left is standard (label chip inside the box). If box height < tag height, tag overflows below — fine, or shrink font. Do inside top-left, with a solid background, dark text, small. Also add index number? "1 · pelicans"? Label only.

Actually place the tag at the top-left inside the box with 1px inset, and a slight corner radius only on the outer corner? Keep it simple: tag at -1px, radius 2px.

Tip: shown on hover below the box — if the box is at the bottom edge, it overflows the stage → clipped by stage overflow hidden. Put the tip above the box: bottom:100%; margin-bottom 6px. If the box is at the top edge (y=290 → about 20% down, fine). Both sample boxes are not at the top, but for general use… pick top? bottom? Compromise: default bottom (top:100%), but if box's top is < 60px, put it above (add .tip-up class in JS: if top < 70 → class). Nice detail.

Box min size: ensure a minimum 2px so hairline boxes are visible.

Also add a subtle "grid overlay" on the image? A faint 100px grid overlay to emphasize the 0-1000 grid — but that might be cluttering. Skip; the crosshair already does that.

Det count text: "2 items".

Now assemble the full file. Also set img { user-select:none; -webkit-user-drag:none; } to avoid drag ghost.

One more: the user said "append the image to the page" — I use a persistent img element; that's fine (it does append). Good.

Let me write out the final code carefully and double-check the JS.

Also add keyboard: Ctrl/Cmd+Enter in textarea triggers render.

Let me write it all out.

Font link:

<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">

CSS vars:

:root{
 --bg:#0c111d; --panel:#121a2c; --panel2:#0e1524; --line:#25314e; --line2:#1b2439;
 --text:#e8ecf6; --mut:#8b96ad; --dim:#5f6d8c;
 --amber:#ffb454; --cyan:#53d8e8; --lime:#9fe870; --red:#ff6b6b;
}

Full JS:

(() => {
const $=s=>document.querySelector(s);
const urlIn=$('#url'), jsonIn=$('#json'),
      renderBtn=$('#renderBtn'), demoBtn=$('#demoBtn'), clearBtn=$('#clearBtn'),
      led=$('#led'), statusText=$('#statusText'), msgEl=$('#msg'),
      stage=$('#stage'), emptyEl=$('#empty'), frame=$('#frame'), elImg=$('#img'),
      boxesEl=$('#boxes'), chV=$('#chV'), chH=$('#chH'), scanEl=$('#scan'),
      statsEl=$('#stats'), detList=$('#detList'), detCount=$('#detCount'),
      readout=$('#readout');

const PALETTE=['#ffb454','#53d8e8','#ff7ab8','#9fe870','#b48cff','#ffd166'];
const SAMPLE=... (jsonIn preset in HTML)

let state={dets:[],url:''};
let firstLayout=true;

const esc=...

function ledSet(kind,text){led.className='led '+kind; statusText.textContent=text;}
function msg(kind,text){msgEl.className='msg '+(kind||''); msgEl.textContent=text;}

function parseDetections(raw){ ... }

function render(){
  const url=urlIn.value.trim();
  if(!url){ledSet('error','NO URL'); msg('err','paste an image URL first'); urlIn.focus(); return;}
  let dets; try{dets=parseDetections(jsonIn.value);}catch(e){ledSet('error','BAD JSON'); msg('err',e.message); jsonIn.focus(); return;}
  ledSet('work','LOADING…'); msg('','fetching image…');
  state.dets=dets; state.url=url;
  const same = elImg.getAttribute('src')===url && elImg.complete && elImg.naturalWidth;
  if(same){ afterLoad(); return;}
  const probe=new Image();
  probe.onload=()=>{ elImg.src=url; afterLoad(); };
  probe.onerror=()=>{ ledSet('error','LOAD FAILED'); msg('err','could not load that URL — check it or CORS'); };
  probe.src=url;
}

function afterLoad(){
  emptyEl.classList.add('hidden');
  frame.classList.remove('hidden');
  firstLayout=true;
  requestAnimationFrame(()=>{ layout(); buildList(); });
  ledSet('ready', `RENDERED · ${state.dets.length} BOX${state.dets.length===1?'':'ES'}`);
  msg('ok','overlay rendered — hover boxes & rows to inspect');
  scanEl.classList.remove('go'); void scanEl.offsetWidth; scanEl.classList.add('go');
}

function dims(){ return {w:elImg.clientWidth, h:elImg.clientHeight}; }

function layout(){
  const {w,h}=dims();
  boxesEl.innerHTML='';
  state.dets.forEach((d,i)=>{
    const l=d.x1/1000*w, t=d.y1/1000*h, bw=Math.max(2,(d.x2-d.x1)/1000*w), bh=Math.max(2,(d.y2-d.y1)/1000*h);
    const el=document.createElement('div');
    el.className='box'+(firstLayout?'':' noanim');
    el.style.cssText=`left:${l}px;top:${t}px;width:${bw}px;height:${bh}px;--c:${d.color}`;
    el.dataset.i=i;
    const tipUp = t<84;
    el.innerHTML=`<span class="tag">${i+1} · ${esc(d.label)}</span>
      <i class="tick t1"></i><i class="tick t2"></i><i class="tick t3"></i><i class="tick t4"></i>
      <span class="tip${tipUp?' up':''}">${esc(d.label)} → x ${Math.round(l)}px · y ${Math.round(t)}px · ${Math.round(bw)}×${Math.round(bh)}px<br>raw [${d.x1}, ${d.y1}, ${d.x2}, ${d.y2}]</span>`;
    el.addEventListener('mouseenter',()=>sync(i,true));
    el.addEventListener('mouseleave',()=>sync(i,false));
    boxesEl.appendChild(el);
    if(firstLayout) el.style.animationDelay=(i*110)+'ms';
  });
  firstLayout=false;
  statsEl.innerHTML=`display <b>${w}×${h}</b> px &nbsp;·&nbsp; 1 unit = <b>${(w/1000).toFixed(3)}</b>x <b>${(h/1000).toFixed(3)}</b>px &nbsp;·&nbsp; nat ${elImg.naturalWidth}×${elImg.naturalHeight}`;
}

Wait: elImg.clientWidth while the frame is hidden — in afterLoad I unhide the frame before the rAF → OK.

buildList:

function buildList(){
  const {w,h}=dims();
  detList.innerHTML='';
  state.dets.forEach((d,i)=>{
    const l=Math.round(d.x1/1000*w), t=Math.round(d.y1/1000*h), bw=Math.round((d.x2-d.x1)/1000*w), bh=Math.round((d.y2-d.y1)/1000*h);
    const li=document.createElement('li');
    li.className='det'; li.dataset.i=i; li.style.setProperty('--c',d.color);
    li.innerHTML=`<span class="chip"></span>
      <span class="det-main"><b class="det-label">${esc(d.label)}</b><code class="raw">[${d.x1}, ${d.y1}, ${d.x2}, ${d.y2}]</code></span>
      <code class="px">${bw}×${bh}px</code>`;
    li.addEventListener('mouseenter',()=>sync(i,true));
    li.addEventListener('mouseleave',()=>sync(i,false));
    li.addEventListener('click',()=>{ const b=boxesEl.children[i]; if(!b) return; b.classList.add('flash'); setTimeout(()=>b.classList.remove('flash'),900); });
    detList.appendChild(li);
  });
  detCount.textContent=`${state.dets.length} item${state.dets.length===1?'':'s'}`;
}

function sync(i,on){
  const b=boxesEl.children[i], l=detList.children[i];
  if(b) b.classList.toggle('hot',on);
  if(l) l.classList.toggle('hot',on);
}

Crosshair:

frame.addEventListener('mousemove',e=>{
  const r=elImg.getBoundingClientRect();
  if(!r.width) return;
  const x=e.clientX-r.left, y=e.clientY-r.top;
  chV.style.left=x+'px'; chH.style.top=y+'px';
  frame.classList.add('tracking');
  const gx=(x/r.width*1000).toFixed(0).padStart(4,'0'), gy=(y/r.height*1000).toFixed(0).padStart(4,'0');
  readout.innerHTML=`grid&nbsp; x <b>${gx}</b> y <b>${gy}</b> &nbsp;·&nbsp; px&nbsp; ${Math.round(x)} × ${Math.round(y)}`;
});
frame.addEventListener('mouseleave',()=>{ frame.classList.remove('tracking'); readout.textContent='move cursor over the image to read grid coords'; });

Resize:

let rT; addEventListener('resize',()=>{ if(!state.dets.length) return; clearTimeout(rT); rT=setTimeout(()=>{ if(!frame.classList.contains('hidden')) layout(); },160); });

layout() with firstLayout=false → noanim class. Good.

Clear:

clearBtn.onclick=()=>{
  state.dets=[]; state.url='';
  frame.classList.add('hidden'); emptyEl.classList.remove('hidden');
  boxesEl.innerHTML=''; detList.innerHTML=''; detCount.textContent='—';
  statsEl.innerHTML='no image loaded';
  elImg.removeAttribute('src');
  readout.textContent='move cursor over the image to read grid coords';
  ledSet('ready','STANDBY'); msg('','stage cleared');
};

elImg.removeAttribute('src') may fire error event → but I don't attach an error handler to elImg (probe is the one with a handler), so safe. Actually elImg doesn't have an onerror — right, probe does.

Demo:

demoBtn.onclick=()=>{
  urlIn.value=demoDataURL();
  render();
};
function demoDataURL(){ ...canvas... return c.toDataURL('image/png'); }

Buttons & keys:

renderBtn.onclick=render;
urlIn.addEventListener('keydown',e=>{if(e.key==='Enter')render();});
jsonIn.addEventListener('keydown',e=>{if(e.key==='Enter'&&(e.ctrlKey||e.metaKey)){e.preventDefault();render();}});

Initial message: 'paste an image URL (or hit Demo scene) and Render'.

Now the full CSS. Also focus-visible styles.

Also: .hidden{display:none!important;}

Box CSS with color-mix fallback: give a default rgba background too:

.box{background:rgba(255,255,255,.04);}
@supports (background: color-mix(in srgb, red 10%, transparent)){ .box{background:color-mix(in srgb, var(--c) 12%, transparent);} ... }

Simpler: use background: color-mix(...) directly — supported in all evergreen browsers since 2023. Just use it directly with a fallback line before.

Box flash animation:

.box.flash{animation:flash .9s ease;}
@keyframes flash{0%,100%{box-shadow:0 0 0 0 transparent}30%{box-shadow:0 0 0 6px color-mix(in srgb, var(--c) 55%, transparent); background:color-mix(in srgb, var(--c) 30%, transparent);}}

noanim:

.box.noanim{animation:none!important;}

Entrance:

.box{animation:boxIn .5s cubic-bezier(.18,.9,.32,1.35) backwards;}
@keyframes boxIn{from{opacity:0; transform:scale(.82);} }

With backwards fill so it's invisible until delay ends. But .hot/.flash use other animations — conflict: the flash animation overrides boxIn, that's fine.

Ticks:

.tick{position:absolute; width:11px; height:11px; border:2px solid var(--c); pointer-events:none;}
.t1{left:-3px; top:-3px; border-right:none; border-bottom:none;}
.t2{right:-3px; top:-3px; border-left:none; border-bottom:none;}
.t3{left:-3px; bottom:-3px; border-right:none; border-top:none;}
.t4{right:-3px; bottom:-3px; border-left:none; border-top:none;}

Tag:

.tag{position:absolute; left:-2px; top:-2px; background:var(--c); color:#101523; font:600 11px/1.5 'Chakra Petch'; letter-spacing:.03em; padding:1px 8px 2px 6px; white-space:nowrap; border-radius:3px 0 3px 0; pointer-events:none;}

Tip:

.tip{position:absolute; left:0; top:100%; margin-top:7px; background:#0b101d; border:1px solid var(--c); color:#dfe6f5; font:11px/1.6 'IBM Plex Mono'; padding:6px 9px; white-space:nowrap; border-radius:4px; opacity:0; transform:translateY(-4px); transition:opacity .18s, transform .18s; pointer-events:none; z-index:6; box-shadow:0 8px 24px rgba(0,0,0,.5);}
.tip.up{top:auto; bottom:100%; margin-top:0; margin-bottom:7px; transform:translateY(4px);}
.box:hover .tip, .box.hot .tip{opacity:1; transform:none;}

Hmm, tip-up transform: base transform translateY(-4px); .up should be translateY(4px). Set explicitly.

Box hot:

.box.hot, .box:hover{background:color-mix(in srgb, var(--c) 26%, transparent); box-shadow:0 0 16px color-mix(in srgb, var(--c) 40%, transparent), inset 0 0 0 1px var(--c);}

Det rows:

.det{display:grid; grid-template-columns:14px 1fr auto; gap:12px; padding:10px 10px; border:1px solid transparent; border-radius:6px; cursor:default; transition:background .15s,border-color .15s;}
.det+.det{margin-top:4px;}
.det:hover,.det.hot{background:color-mix(in srgb, var(--c) 10%, transparent); border-color:color-mix(in srgb, var(--c) 45%, transparent);}
.chip{width:12px; height:12px; border-radius:3px; margin-top:4px; background:var(--c); box-shadow:0 0 9px color-mix(in srgb, var(--c) 70%, transparent);}
.det-main{display:flex; flex-direction:column; gap:2px; min-width:0;}
.det-label{font:600 13px 'Chakra Petch'; color:var(--text); letter-spacing:.03em;}
.raw{font:11px 'IBM Plex Mono'; color:var(--mut);}
.px{font:11.5px 'IBM Plex Mono'; color:var(--c); align-self:start; margin-top:2px;}

Empty state:

.empty{position:absolute; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:14px; color:var(--dim); pointer-events:none;}
.ghost{width:min(340px,70%); aspect-ratio:16/10; border:1.5px dashed #31405f; position:relative;}
.ghost i{position:absolute; width:14px; height:14px; border:2px solid var(--amber);}
... 4 corners
.empty p{font:12px 'IBM Plex Mono';}
.empty b{color:var(--mut); font-weight:600;}

Add a slow blink to the ghost corners:

@keyframes ghostPulse{0%,100%{opacity:.9}50%{opacity:.35}}
.ghost i{animation:ghostPulse 2.6s ease-in-out infinite;}
.ghost i:nth-child(2){animation-delay:.2s} ...

Masthead:

.masthead{display:flex; align-items:center; justify-content:space-between; padding:16px 22px 14px; max-width:1440px; margin:0 auto;}
.brand{display:flex; align-items:center; gap:14px;}
.brand h1{font:700 24px/1 'Chakra Petch'; letter-spacing:.02em; color:var(--text);}
.brand h1 span{color:var(--amber);}
.brand .sub{font:11.5px 'IBM Plex Mono'; color:var(--dim); margin-top:3px;}
.status{display:flex; align-items:center; font:600 11px 'Chakra Petch'; letter-spacing:.16em; color:var(--mut);}

Ruler below the masthead:

.ruler{max-width:1440px; margin:0 auto; height:12px; position:relative; border-bottom:1px solid var(--line);}
.ruler::before{content:""; position:absolute; inset:0; background:
 linear-gradient(90deg, #3a4a70 0 1px, transparent 1px) left bottom / 12px 6px repeat-x,
 linear-gradient(90deg, #4d5f8c 0 1px, transparent 1px) left bottom / 60px 11px repeat-x;
 opacity:.7;}

Wait, background-size and repeat-x in the shorthand: linear-gradient(...) left bottom / 12px 6px repeat-x is valid.

Footer:

.foot{max-width:1440px;margin:0 auto;padding:8px 22px 26px; display:flex; justify-content:space-between; gap:12px; font:11px 'IBM Plex Mono'; color:#4d5a78;}

Stage min-height maybe 520px.

img: max-width:100%; height:auto; max-height:70vh; inside frame. Frame: display:inline-block? The stage is flex center; frame is position relative; width fit-content. line-height:0 to avoid the gap.

But boxes are absolute inside frame — frame size = img size (since img is the only in-flow child). Good.

overflow: stage overflow hidden so the checker doesn't overflow; but box tips near the edges may clip — acceptable.

Actually the stage padding is 28px, giving some room for tips above the box.

prefers-reduced-motion:

@media (prefers-reduced-motion:reduce){*{animation:none!important; transition:none!important;}}

Selection color: amber background, dark text.

Textarea scrollbar styling (webkit) subtle:

textarea::-webkit-scrollbar{width:10px}
textarea::-webkit-scrollbar-thumb{background:#26314e; border-radius:5px; border:2px solid #0b101d;}

Now, prefill the JSON in the HTML:

<textarea id="json" spellcheck="false" aria-label="detections JSON">[
	{"bbox_2d": [195, 290, 370, 780], "label": "pelicans"},
	{"bbox_2d": [445, 320, 675, 850], "label": "pelicans"}
]</textarea>

The user used tabs — fine.

URL placeholder: "https://example.com/two-pelicans.jpg".

Let me re-check the parse: item might not have a label → default to 'box N'. Good.

One more thing: the stats show "1 unit = 0.78px" — good for a clear mapping.

Potential issue: afterLoad on probe load — elImg.src=url; if the img element is new and the frame is shown, layout via rAF uses clientWidth. The elImg's own decoding may not be done, but since dimensions are known, layout doesn't need pixels. OK.

Also img { image-rendering:auto } default.

Demo pelicans: draw them so they roughly match the bbox. Box 0: x 195–370 (w 175), y 290–780 (h 490) — tall thin pelicans. My p() uses s=(y2-y1)=490; body ellipse rx=s*0.28≈137 — wider than the box (175)… center cx=282, rx 137 → spans 145–419, overshoots the box. Shrink: rx = (x2-x1)*0.42 ≈ 73. Pass both w and h:

const p=(x1,y1,x2,y2)=>{
  const cx=(x1+x2)/2, cy=(y1+y2)/2, bw=x2-x1, bh=y2-y1;
  const dark='#0d141f';
  g.fillStyle=dark;
  g.beginPath(); g.ellipse(cx, cy+bh*0.14, bw*0.38, bh*0.22, 0, 0, Math.PI*2); g.fill(); // body
  g.strokeStyle=dark; g.lineCap='round'; g.lineWidth=bw*0.16;
  g.beginPath(); g.moveTo(cx+bw*0.05, cy+bh*0.02);
  g.quadraticCurveTo(cx+bw*0.22, cy-bh*0.15, cx+bw*0.1, cy-bh*0.28); g.stroke(); // neck
  g.fillStyle=dark;
  g.beginPath(); g.arc(cx+bw*0.1, cy-bh*0.3, bw*0.14, 0, Math.PI*2); g.fill(); // head
  g.fillStyle='#f4a340';
  g.beginPath(); g.moveTo(cx+bw*0.2, cy-bh*0.33); g.lineTo(cx+bw*0.5, cy-bh*0.2); g.lineTo(cx+bw*0.18, cy-bh*0.21); g.closePath(); g.fill(); // beak
  g.fillStyle='rgba(10,16,26,.35)';
  g.beginPath(); g.ellipse(cx, cy+bh*0.48, bw*0.34, bh*0.05, 0, 0, Math.PI*2); g.fill(); // reflection
};

Beak goes to cx+bw*0.5 = right edge — OK, inside.

Add some birds in the sky for life:

g.strokeStyle='rgba(20,28,40,.8)'; g.lineWidth=3;
[[300,140],[420,100],[560,170]].forEach(([x,y])=>{ g.beginPath(); g.arc(x-12,y,12,Math.PI*1.1,Math.PI*1.9); g.arc(x+12,y,12,Math.PI*1.1,Math.PI*1.9); g.stroke(); });

Simplify: draw a "m" shape with two arcs. Fine.

Also add a title text on the canvas? No, keep it clean.

Now, put it all together. Also add <meta name="color-scheme" content="dark">.

Let me review the header LED default: class 'led ready', text 'STANDBY'.

Edge: statsEl initial: <span class="dim">no image loaded</span>.

I think I'm ready to write the final. Let me also double-check the color-mix usage in CSS with var(--c) — supported.

One concern: .box has animation: boxIn ... backwards; and .noanim sets animation:none — good. But .flash on an existing box: .flash's animation overrides (specificity: .box.flash > .box) OK.

Hover transform? Not adding transform on hover (conflicts with entrance). Only shadow/bg. Good.

Det count initial '—'.

Also: on mobile stage height smaller; fine.

Writing the final HTML now. Keep the code tidy with comments.

Response language: English. Short intro sentence, then the code block, then maybe 3 bullets of notes. Keep it concise.

Response

Here's a self-contained page — an annotation workbench where you paste an image URL plus the detection JSON, and it measures the loaded image, maps the 0–1000 coordinates to real pixels, and draws labelled, hoverable boxes over it. A "Demo scene" button generates a matching canvas image so you can see the overlay working with zero setup.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<title>bbox·lab — 0–1000 coordinate inspector</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{
  --bg:#0c111d; --panel:#121a2c; --well:#0b101d; --line:#263353; --line2:#1c2740;
  --text:#e8ecf6; --mut:#8b96ad; --dim:#5b6884;
  --amber:#ffb454; --cyan:#53d8e8; --lime:#9fe870; --red:#ff6b6b;
}
*{box-sizing:border-box; margin:0}
html,body{min-height:100%}
body{
  font:14.5px/1.55 "IBM Plex Sans", system-ui, sans-serif;
  color:var(--text);
  background:
    radial-gradient(900px 460px at 88% -8%, rgba(83,216,232,.055), transparent 62%),
    radial-gradient(760px 460px at -8% 108%, rgba(255,180,84,.05), transparent 60%),
    var(--bg);
}
body::before{ /* measurement grid */
  content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
  background:
    linear-gradient(rgba(140,160,210,.05) 1px, transparent 1px),
    linear-gradient(90deg, rgba(140,160,210,.05) 1px, transparent 1px);
  background-size:34px 34px;
}
body::after{ /* slow light sweep */
  content:""; position:fixed; inset:0; pointer-events:none; z-index:0;
  background:linear-gradient(105deg, transparent 44%, rgba(130,170,255,.045) 50%, transparent 56%) 0 0/300% 100% no-repeat;
  animation:drift 16s linear infinite;
}
@keyframes drift{from{background-position:130% 0}to{background-position:-70% 0}}
::selection{background:var(--amber); color:#181004}
button,input,textarea{font:inherit; color:inherit}
.hidden{display:none !important}
.mono{font-family:"IBM Plex Mono",monospace}

/* ── masthead ─────────────────────────────── */
.masthead{position:relative; z-index:1; display:flex; align-items:center; justify-content:space-between;
  gap:16px; padding:18px 24px 15px; max-width:1440px; margin:0 auto}
.brand{display:flex; align-items:center; gap:14px}
.brand svg{flex:none}
.brand h1{font:700 25px/1 "Chakra Petch"; letter-spacing:.02em}
.brand h1 em{font-style:normal; color:var(--amber)}
.brand .sub{font:11.5px "IBM Plex Mono"; color:var(--dim); margin-top:4px}
.status{display:flex; align-items:center; gap:9px; font:600 11px "Chakra Petch"; letter-spacing:.18em; color:var(--mut)}
.led{width:9px; height:9px; border-radius:50%; background:#3a4763}
.led.ready{background:var(--lime); box-shadow:0 0 10px var(--lime); animation:pulse 2.4s ease-in-out infinite}
.led.work{background:var(--amber); box-shadow:0 0 10px var(--amber); animation:pulse .9s ease-in-out infinite}
.led.error{background:var(--red); box-shadow:0 0 10px var(--red)}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.ruler{position:relative; z-index:1; max-width:1440px; margin:0 auto; height:13px; border-bottom:1px solid var(--line2)}
.ruler::before{content:""; position:absolute; inset:0; opacity:.65;
  background:
    linear-gradient(90deg, #3c4c74 0 1px, transparent 1px) left bottom/12px 6px repeat-x,
    linear-gradient(90deg, #55689a 0 1px, transparent 1px) left bottom/60px 11px repeat-x}

/* ── workbench ────────────────────────────── */
.workbench{position:relative; z-index:1; display:grid; grid-template-columns:minmax(340px,432px) minmax(0,1fr);
  gap:18px; align-items:start; max-width:1440px; margin:0 auto; padding:20px 24px}
@media(max-width:940px){.workbench{grid-template-columns:1fr}}
.panel{background:var(--panel); border:1px solid var(--line2); border-radius:8px;
  box-shadow:0 14px 34px rgba(2,6,16,.45)}
.panel-head{display:flex; align-items:baseline; justify-content:space-between; gap:12px;
  padding:12px 16px; border-bottom:1px solid var(--line2)}
.panel-head h2{font:600 12px "Chakra Petch"; letter-spacing:.2em; text-transform:uppercase; color:var(--mut)}
.panel-head h2 b{color:var(--amber); font-weight:700; margin-right:8px}
.panel-head .hint{font:11px "IBM Plex Mono"; color:var(--dim)}

label{display:block; margin:16px 16px 7px; padding:0 16px; font:600 11px "Chakra Petch";
  letter-spacing:.16em; text-transform:uppercase; color:#7f8db0}
label{margin:16px 0 7px}
#url,#json{display:block; width:calc(100% - 32px); margin:0 16px; background:var(--well);
  border:1px solid #2a3550; border-radius:6px; padding:10px 12px; color:var(--text);
  font:13px/1.55 "IBM Plex Mono"; transition:border-color .18s, box-shadow .18s}
#url::placeholder{color:#49557333}
#url::placeholder,#json::placeholder{color:#495573}
#url:focus,#json:focus{outline:none; border-color:var(--amber); box-shadow:0 0 0 3px rgba(255,180,84,.16)}
#json{min-height:232px; resize:vertical; tab-size:2; margin-bottom:4px}
#json::-webkit-scrollbar{width:10px}
#json::-webkit-scrollbar-thumb{background:#26314e; border-radius:5px; border:2px solid var(--well)}
.actions{display:flex; flex-wrap:wrap; gap:10px; padding:14px 16px 0}
.btn{font:600 12px "Chakra Petch"; letter-spacing:.1em; text-transform:uppercase; padding:10px 16px;
  border-radius:5px; border:1px solid; cursor:pointer; background:transparent;
  transition:transform .15s, box-shadow .2s, background .2s, color .2s, border-color .2s}
.btn:active{transform:translateY(1px)}
.btn.primary{background:var(--amber); border-color:#ffcf95; color:#1a1206}
.btn.primary:hover{transform:translateY(-2px); box-shadow:0 8px 20px rgba(255,180,84,.35)}
.btn.ghost{color:#9fb0d0; border-color:#33415f}
.btn.ghost:hover{color:var(--text); border-color:var(--cyan); box-shadow:inset 0 0 0 1px rgba(83,216,232,.25)}
.btn:focus-visible{outline:2px solid var(--cyan); outline-offset:2px}
.msg{min-height:19px; margin:10px 16px 0; padding:0 16px; font:11.5px "IBM Plex Mono"; color:var(--dim)}
.msg.err{color:#ff9a9a}
.msg.ok{color:var(--lime)}
.detwrap{padding:12px 16px 18px}
.det-list{list-style:none}
.det{display:grid; grid-template-columns:14px 1fr auto; gap:12px; align-items:start;
  padding:10px; border:1px solid transparent; border-radius:6px; cursor:default;
  transition:background .15s, border-color .15s}
.det + .det{margin-top:4px}
.det:hover,.det.hot{background:color-mix(in srgb, var(--c) 11%, transparent);
  border-color:color-mix(in srgb, var(--c) 45%, transparent)}
.chip{width:12px; height:12px; border-radius:3px; margin-top:4px; background:var(--c);
  box-shadow:0 0 9px color-mix(in srgb, var(--c) 70%, transparent)}
.det-main{display:flex; flex-direction:column; gap:2px; min-width:0}
.det-label{font:600 13.5px "Chakra Petch"; letter-spacing:.03em}
.raw{font:11px "IBM Plex Mono"; color:var(--mut); word-break:break-all}
.px{align-self:start; margin-top:2px; font:11.5px "IBM Plex Mono"; color:var(--c)}
.empty-list{font:11.5px "IBM Plex Mono"; color:var(--dim); padding:6px 2px}

/* ── stage ────────────────────────────────── */
.stats{font:11.5px "IBM Plex Mono"; color:#9fb0d0; text-align:right}
.stats b{color:var(--cyan); font-weight:600}
.stats .dim{color:var(--dim)}
.stage{position:relative; overflow:hidden; border-bottom:1px solid var(--line2); border-radius:0 0 8px 8px;
  min-height:480px; padding:30px; display:flex; align-items:center; justify-content:center;
  background-color:#101829;
  background-image:
    linear-gradient(45deg,#141e33 25%,transparent 25%,transparent 75%,#141e33 75%),
    linear-gradient(45deg,#141e33 25%,transparent 25%,transparent 75%,#141e33 75%);
  background-size:28px 28px; background-position:0 0,14px 14px}
.empty{position:absolute; inset:0; display:flex; flex-direction:column; align-items:center;
  justify-content:center; gap:16px; color:var(--dim); pointer-events:none}
.ghost{width:min(330px,68%); aspect-ratio:16/10; border:1.5px dashed #31405f; position:relative}
.ghost i{position:absolute; width:15px; height:15px; border:2px solid var(--amber); animation:ghostPulse 2.8s ease-in-out infinite}
.ghost i:nth-child(2){animation-delay:.3s}.ghost i:nth-child(3){animation-delay:.6s}.ghost i:nth-child(4){animation-delay:.9s}
.ghost .g1{left:-2px; top:-2px; border-right:0; border-bottom:0}
.ghost .g2{right:-2px; top:-2px; border-left:0; border-bottom:0}
.ghost .g3{left:-2px; bottom:-2px; border-right:0; border-top:0}
.ghost .g4{right:-2px; bottom:-2px; border-left:0; border-top:0}
@keyframes ghostPulse{0%,100%{opacity:.95}50%{opacity:.3}}
.empty p{font:12px/1.7 "IBM Plex Mono"; text-align:center}
.empty p b{color:var(--mut); font-weight:600}
.frame{position:relative; line-height:0; border:1px solid #2c3a5c; box-shadow:0 16px 46px rgba(0,0,0,.55)}
#img{display:block; max-width:100%; max-height:72vh; height:auto; user-select:none; -webkit-user-drag:none}
#boxes{position:absolute; inset:0}
.box{position:absolute; border:1.5px solid var(--c); border-radius:2px; cursor:pointer;
  background:rgba(255,255,255,.05);
  background:color-mix(in srgb, var(--c) 12%, transparent);
  transition:background .2s, box-shadow .2s;
  animation:boxIn .5s cubic-bezier(.18,.9,.32,1.35) backwards}
.box.noanim{animation:none !important}
@keyframes boxIn{from{opacity:0; transform:scale(.82)}}
.box:hover,.box.hot{background:color-mix(in srgb, var(--c) 27%, transparent);
  box-shadow:0 0 16px color-mix(in srgb, var(--c) 42%, transparent), inset 0 0 0 1px var(--c)}
.box.flash{animation:flash .9s ease}
@keyframes flash{0%,100%{box-shadow:0 0 0 0 transparent}
  35%{box-shadow:0 0 0 7px color-mix(in srgb, var(--c) 55%, transparent); background:color-mix(in srgb, var(--c) 32%, transparent)}}
.tick{position:absolute; width:11px; height:11px; border:2px solid var(--c); pointer-events:none}
.t1{left:-3px; top:-3px; border-right:0; border-bottom:0}
.t2{right:-3px; top:-3px; border-left:0; border-bottom:0}
.t3{left:-3px; bottom:-3px; border-right:0; border-top:0}
.t4{right:-3px; bottom:-3px; border-left:0; border-top:0}
.tag{position:absolute; left:-2px; top:-2px; background:var(--c); color:#101523;
  font:600 11px/1.6 "Chakra Petch"; letter-spacing:.03em; padding:1px 9px 1px 7px;
  white-space:nowrap; border-radius:3px 0 3px 0; pointer-events:none}
.tip{position:absolute; left:0; top:100%; margin-top:7px; background:var(--well); border:1px solid var(--c);
  color:#dfe6f5; font:11px/1.65 "IBM Plex Mono"; padding:6px 10px; white-space:nowrap; border-radius:4px;
  box-shadow:0 8px 24px rgba(0,0,0,.5); opacity:0; transform:translateY(-4px);
  transition:opacity .18s, transform .18s; pointer-events:none; z-index:6}
.tip.up{top:auto; bottom:100%; margin-top:0; margin-bottom:7px; transform:translateY(4px)}
.box:hover .tip,.box.hot .tip{opacity:1; transform:none}
.ch-v,.ch-h{position:absolute; background:rgba(83,216,232,.4); pointer-events:none; opacity:0; transition:opacity .15s; z-index:4}
.ch-v{top:0; bottom:0; width:1px}
.ch-h{left:0; right:0; height:1px}
.frame.tracking .ch-v,.frame.tracking .ch-h{opacity:1}
.scan{position:absolute; left:0; right:0; top:-4%; height:2px; pointer-events:none; opacity:0; z-index:7;
  background:linear-gradient(90deg, transparent, rgba(83,216,232,.85), transparent);
  box-shadow:0 0 18px rgba(83,216,232,.55)}
.scan.go{animation:sweep 1.1s ease .05s 1}
@keyframes sweep{0%{top:-3%; opacity:0}12%{opacity:.95}88%{opacity:.95}100%{top:103%; opacity:0}}
.stage-foot{display:flex; justify-content:space-between; align-items:center; gap:14px; padding:10px 16px;
  font:11.5px "IBM Plex Mono"; color:#7f8db0}
#readout b{color:var(--cyan); font-weight:600}
@media(max-width:640px){#readout{display:none}}

.foot{position:relative; z-index:1; max-width:1440px; margin:0 auto; padding:6px 24px 30px;
  display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap;
  font:11px "IBM Plex Mono"; color:#46536f}
@media (prefers-reduced-motion:reduce){*,*::before,*::after{animation:none !important; transition:none !important}}
</style>
</head>
<body>

<header class="masthead">
  <div class="brand">
    <svg width="34" height="34" viewBox="0 0 34 34" aria-hidden="true">
      <path d="M3 10 V3 H10 M24 3 H31 V10 M31 24 V31 H24 M10 31 H3 V24" stroke="#ffb454" stroke-width="2.6" fill="none"/>
      <circle cx="17" cy="17" r="5" fill="#53d8e8"/>
    </svg>
    <div>
      <h1>bbox<em>·lab</em></h1>
      <p class="sub">normalized 0–1000 coords → pixel overlay</p>
    </div>
  </div>
  <div class="status"><span class="led ready" id="led"></span><span id="statusText">STANDBY</span></div>
</header>
<div class="ruler" aria-hidden="true"></div>

<main class="workbench">

  <!-- 01 · input -->
  <section class="panel" aria-label="inputs">
    <div class="panel-head"><h2><b>01</b>input</h2><span class="hint">URL + detections</span></div>

    <label for="url">Image URL</label>
    <input id="url" type="url" placeholder="https://example.com/two-pelicans.jpg" autocomplete="off" spellcheck="false">

    <label for="json">Detections — JSON</label>
    <textarea id="json" spellcheck="false" aria-label="detections JSON">[
	{"bbox_2d": [195, 290, 370, 780], "label": "pelicans"},
	{"bbox_2d": [445, 320, 675, 850], "label": "pelicans"}
]</textarea>

    <div class="actions">
      <button class="btn primary" id="renderBtn">Render boxes</button>
      <button class="btn ghost" id="demoBtn" title="Generates a matching canvas image so you can test instantly">Demo scene</button>
      <button class="btn ghost" id="clearBtn">Clear</button>
    </div>
    <p class="msg" id="msg" aria-live="polite">paste an image URL (or hit “Demo scene”), then render</p>

    <div class="panel-head" style="border-top:1px solid var(--line2)"><h2><b>02</b>detections</h2><span class="hint" id="detCount"></span></div>
    <div class="detwrap"><ul class="det-list" id="detList"><li class="empty-list">nothing parsed yet</li></ul></div>
  </section>

  <!-- 03 · stage -->
  <section class="panel" aria-label="stage">
    <div class="panel-head"><h2><b>03</b>stage</h2><span class="stats" id="stats"><span class="dim">no image loaded</span></span></div>
    <div class="stage">
      <div class="empty" id="empty">
        <div class="ghost"><i class="g1"></i><i class="g2"></i><i class="g3"></i><i class="g4"></i></div>
        <p><b>awaiting image</b><br>render to project the 0–1000 boxes onto real pixels</p>
      </div>
      <div class="frame hidden" id="frame">
        <img id="img" alt="loaded source image">
        <div id="boxes"></div>
        <span class="ch-v" id="chV"></span>
        <span class="ch-h" id="chH"></span>
        <span class="scan" id="scan"></span>
      </div>
    </div>
    <div class="stage-foot">
      <span id="readout">move the cursor over the image to read grid coords</span>
      <span>boxes map 0–1000 → displayed px</span>
    </div>
  </section>
</main>

<footer class="foot">
  <span>bbox·lab — a coordinate inspector</span>
  <span>runs entirely in your browser · nothing is uploaded</span>
</footer>

<script>
(() => {
  const $ = s => document.querySelector(s);
  const urlIn=$('#url'), jsonIn=$('#json'),
        renderBtn=$('#renderBtn'), demoBtn=$('#demoBtn'), clearBtn=$('#clearBtn'),
        led=$('#led'), statusText=$('#statusText'), msgEl=$('#msg'),
        emptyEl=$('#empty'), frame=$('#frame'), elImg=$('#img'), boxesEl=$('#boxes'),
        chV=$('#chV'), chH=$('#chH'), scanEl=$('#scan'),
        statsEl=$('#stats'), detList=$('#detList'), detCount=$('#detCount'), readout=$('#readout');

  const PALETTE = ['#ffb454','#53d8e8','#ff7ab8','#9fe870','#b48cff','#ffd166'];
  const state = { dets: [], url: '' };
  let firstLayout = true, rT;

  const esc = s => String(s).replace(/[&<>"']/g, m => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[m]));
  const ledSet = (k,t) => { led.className = 'led ' + k; statusText.textContent = t; };
  const msg = (k,t) => { msgEl.className = 'msg ' + (k || ''); msgEl.textContent = t; };

  function parseDetections(raw){
    let data;
    try { data = JSON.parse(raw); } catch(e){ throw new Error('JSON parse: ' + e.message); }
    if (!Array.isArray(data)) throw new Error('expected a JSON array of objects');
    if (!data.length) throw new Error('array is empty');
    return data.map((d,i) => {
      const b = d && d.bbox_2d;
      if (!b || !Array.isArray(b) || b.length !== 4) throw new Error('item ' + i + ': bbox_2d must be [x1, y1, x2, y2]');
      if (b.some(n => typeof n !== 'number' || !isFinite(n))) throw new Error('item ' + i + ': bbox_2d values must be numbers');
      const label = (d.label != null && String(d.label).trim()) ? String(d.label).trim() : 'box ' + (i+1);
      return { label, x1:b[0], y1:b[1], x2:b[2], y2:b[3], color: PALETTE[i % PALETTE.length] };
    });
  }

  function render(){
    const url = urlIn.value.trim();
    if (!url){ ledSet('error','NO URL'); msg('err','paste an image URL first'); urlIn.focus(); return; }
    let dets;
    try { dets = parseDetections(jsonIn.value); }
    catch(e){ ledSet('error','BAD JSON'); msg('err', e.message); jsonIn.focus(); return; }
    state.dets = dets; state.url = url;
    ledSet('work','LOADING…'); msg('','fetching image…');

    if (elImg.getAttribute('src') === url && elImg.complete && elImg.naturalWidth){ afterLoad(); return; }
    const probe = new Image();
    probe.onload  = () => { elImg.src = url; afterLoad(); };
    probe.onerror = () => { ledSet('error','LOAD FAILED'); msg('err','could not load that URL — check it (or CORS)'); };
    probe.src = url;
  }

  function afterLoad(){
    emptyEl.classList.add('hidden');
    frame.classList.remove('hidden');
    firstLayout = true;
    requestAnimationFrame(() => { layout(); buildList(); });
    ledSet('ready', 'RENDERED · ' + state.dets.length + ' BOX' + (state.dets.length === 1 ? '' : 'ES'));
    msg('ok','overlay rendered — hover boxes and list rows to inspect');
    scanEl.classList.remove('go'); void scanEl.offsetWidth; scanEl.classList.add('go');
  }

  const dims = () => ({ w: elImg.clientWidth || elImg.naturalWidth, h: elImg.clientHeight || elImg.naturalHeight });

  function layout(){
    const { w, h } = dims();
    boxesEl.innerHTML = '';
    state.dets.forEach((d, i) => {
      const l  = d.x1 / 1000 * w;
      const t  = d.y1 / 1000 * h;
      const bw = Math.max(2, (d.x2 - d.x1) / 1000 * w);
      const bh = Math.max(2, (d.y2 - d.y1) / 1000 * h);
      const el = document.createElement('div');
      el.className = 'box' + (firstLayout ? '' : ' noanim');
      el.style.cssText = `left:${l}px; top:${t}px; width:${bw}px; height:${bh}px; --c:${d.color}`;
      el.dataset.i = i;
      el.innerHTML =
        `<span class="tag">${i+1} · ${esc(d.label)}</span>
         <i class="tick t1"></i><i class="tick t2"></i><i class="tick t3"></i><i class="tick t4"></i>
         <span class="tip${t < 90 ? ' up' : ''}">${esc(d.label)} → x ${Math.round(l)}px · y ${Math.round(t)}px · ${Math.round(bw)}×${Math.round(bh)}px<br>raw [${d.x1}, ${d.y1}, ${d.x2}, ${d.y2}]</span>`;
      el.addEventListener('mouseenter', () => sync(i, true));
      el.addEventListener('mouseleave', () => sync(i, false));
      if (firstLayout) el.style.animationDelay = (i * 110) + 'ms';
      boxesEl.appendChild(el);
    });
    firstLayout = false;
    statsEl.innerHTML =
      `display <b>${w} × ${h}</b> px &nbsp;·&nbsp; 1 unit = <b>${(w/1000).toFixed(3)}</b>px x <b>${(h/1000).toFixed(3)}</b>px &nbsp;·&nbsp; nat ${elImg.naturalWidth}×${elImg.naturalHeight}`;
  }

  function buildList(){
    const { w, h } = dims();
    detList.innerHTML = '';
    state.dets.forEach((d, i) => {
      const li = document.createElement('li');
      li.className = 'det'; li.dataset.i = i; li.style.setProperty('--c', d.color);
      li.innerHTML =
        `<span class="chip"></span>
         <span class="det-main"><b class="det-label">${esc(d.label)}</b>
         <code class="raw">[${d.x1}, ${d.y1}, ${d.x2}, ${d.y2}]</code></span>
         <code class="px">${Math.round((d.x2-d.x1)/1000*w)}×${Math.round((d.y2-d.y1)/1000*h)}px</code>`;
      li.addEventListener('mouseenter', () => sync(i, true));
      li.addEventListener('mouseleave', () => sync(i, false));
      li.addEventListener('click', () => {
        const b = boxesEl.children[i]; if (!b) return;
        b.classList.add('flash'); setTimeout(() => b.classList.remove('flash'), 950);
      });
      detList.appendChild(li);
    });
    detCount.textContent = state.dets.length + ' item' + (state.dets.length === 1 ? '' : 's');
  }

  function sync(i, on){
    const b = boxesEl.children[i], l = detList.children[i];
    if (b) b.classList.toggle('hot', on);
    if (l) l.classList.toggle('hot', on);
  }

  /* crosshair + live grid readout */
  frame.addEventListener('mousemove', e => {
    const r = elImg.getBoundingClientRect();
    if (!r.width) return;
    const x = e.clientX - r.left, y = e.clientY - r.top;
    chV.style.left = x + 'px'; chH.style.top = y + 'px';
    frame.classList.add('tracking');
    const gx = String(Math.round(x / r.width * 1000)).padStart(4,'0');
    const gy = String(Math.round(y / r.height * 1000)).padStart(4,'0');
    readout.innerHTML = `grid &nbsp;x <b>${gx}</b> &nbsp;y <b>${gy}</b> &nbsp;·&nbsp; px &nbsp;${Math.round(x)} × ${Math.round(y)}`;
  });
  frame.addEventListener('mouseleave', () => {
    frame.classList.remove('tracking');
    readout.textContent = 'move the cursor over the image to read grid coords';
  });

  /* keep boxes accurate on resize */
  addEventListener('resize', () => {
    if (!state.dets.length || frame.classList.contains('hidden')) return;
    clearTimeout(rT);
    rT = setTimeout(layout, 160);
  });

  /* self-contained demo image: silhouettes drawn at the sample bboxes */
  function demoDataURL(){
    const c = document.createElement('canvas'); c.width = 1000; c.height = 1000;
    const g = c.getContext('2d');
    let sky = g.createLinearGradient(0,0,0,640);
    sky.addColorStop(0,'#1f3355'); sky.addColorStop(.7,'#6b4a63'); sky.addColorStop(1,'#c96f4a');
    g.fillStyle = sky; g.fillRect(0,0,1000,640);
    g.fillStyle = 'rgba(255,214,150,.9)'; g.beginPath(); g.arc(770,470,72,0,7); g.fill();
    let wat = g.createLinearGradient(0,640,0,1000);
    wat.addColorStop(0,'#2e4a6b'); wat.addColorStop(1,'#141f33');
    g.fillStyle = wat; g.fillRect(0,640,1000,360);
    g.strokeStyle = 'rgba(190,215,255,.10)'; g.lineWidth = 1.5;
    for (let i = 0; i < 26; i++){ const y = 665 + Math.random()*320;
      g.beginPath(); g.moveTo(Math.random()*160, y); g.lineTo(1000 - Math.random()*160, y); g.stroke(); }
    g.strokeStyle = 'rgba(18,26,40,.75)'; g.lineWidth = 3; g.lineCap = 'round';
    [[290,130],[430,95],[560,165]].forEach(([x,y]) => {
      g.beginPath(); g.arc(x-13,y,13,Math.PI*1.05,Math.PI*1.95); g.stroke();
      g.beginPath(); g.arc(x+13,y,13,Math.PI*1.05,Math.PI*1.95); g.stroke();
    });
    const pelican = (x1,y1,x2,y2) => {
      const cx=(x1+x2)/2, cy=(y1+y2)/2, bw=x2-x1, bh=y2-y1, dark='#0d141f';
      g.fillStyle = dark; g.beginPath(); g.ellipse(cx, cy+bh*.14, bw*.38, bh*.22, 0, 0, 7); g.fill();
      g.strokeStyle = dark; g.lineWidth = bw*.16;
      g.beginPath(); g.moveTo(cx+bw*.05, cy+bh*.02);
      g.quadraticCurveTo(cx+bw*.24, cy-bh*.16, cx+bw*.1, cy-bh*.28); g.stroke();
      g.fillStyle = dark; g.beginPath(); g.arc(cx+bw*.1, cy-bh*.3, bw*.14, 0, 7); g.fill();
      g.fillStyle = '#f4a340'; g.beginPath();
      g.moveTo(cx+bw*.2, cy-bh*.33); g.lineTo(cx+bw*.52, cy-bh*.2); g.lineTo(cx+bw*.18, cy-bh*.21);
      g.closePath(); g.fill();
      g.fillStyle = 'rgba(10,16,26,.35)'; g.beginPath(); g.ellipse(cx, cy+bh*.48, bw*.34, bh*.05, 0, 0, 7); g.fill();
    };
    pelican(195,290,370,780); pelican(445,320,675,850);
    return c.toDataURL('image/png');
  }

  renderBtn.addEventListener('click', render);
  demoBtn.addEventListener('click', () => { urlIn.value = demoDataURL(); render(); });
  clearBtn.addEventListener('click', () => {
    state.dets = []; state.url = '';
    frame.classList.add('hidden'); emptyEl.classList.remove('hidden');
    boxesEl.innerHTML = ''; elImg.removeAttribute('src');
    detList.innerHTML = '<li class="empty-list">nothing parsed yet</li>'; detCount.textContent = '—';
    statsEl.innerHTML = '<span class="dim">no image loaded</span>';
    readout.textContent = 'move the cursor over the image to read grid coords';
    ledSet('ready','STANDBY'); msg('','stage cleared');
  });
  urlIn.addEventListener('keydown', e => { if (e.key === 'Enter') render(); });
  jsonIn.addEventListener('keydown', e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)){ e.preventDefault(); render(); } });
})();
</script>
</body>
</html>

How it works

  • Render boxes loads the URL, waits for the image, then reads clientWidth/clientHeight (reported in the stage header alongside the natural size) and maps every bbox_2d value via x/1000 × width, y/1000 × height.
  • Boxes get index + label tags, corner brackets, a hover tooltip with the scaled px values and the raw 0–1000 coords, and stay in sync with the detection list (hover either side to cross-highlight; click a row to flash its box).
  • Extras: a live crosshair readout showing the cursor position in both grid and pixel space, re-projection on window resize, JSON validation with specific error messages, and Ctrl/Cmd+Enter in the textarea triggers rendering.
  • Demo scene paints a canvas whose pelican silhouettes sit at exactly your sample coordinates, so you can verify the overlay math immediately — the sample JSON is pre-filled in the textarea.

Token usage

211 input, 26,813 output, {"completion_tokens_details": {"reasoning_tokens": 17576}}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment