Spaces:
Sleeping
Sleeping
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
| <title>SW β Fish Identifier</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #0f1117; | |
| --surface: #1a1d27; | |
| --border: #2d3147; | |
| --accent: #3b82f6; | |
| --accent2: #10b981; | |
| --warn: #f59e0b; | |
| --text: #e2e8f0; | |
| --muted: #64748b; | |
| --radius: 12px; | |
| --shadow: 0 4px 24px rgba(0,0,0,.45); | |
| } | |
| body { | |
| background: var(--bg); | |
| color: var(--text); | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; | |
| min-height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| } | |
| header { | |
| padding: 1rem 1.5rem; | |
| border-bottom: 1px solid var(--border); | |
| display: flex; | |
| align-items: center; | |
| gap: .75rem; | |
| background: var(--surface); | |
| } | |
| header h1 { | |
| font-size: 1.2rem; | |
| font-weight: 700; | |
| letter-spacing: -.02em; | |
| } | |
| header .badge { | |
| font-size: .7rem; | |
| background: var(--accent); | |
| color: #fff; | |
| border-radius: 4px; | |
| padding: 2px 6px; | |
| text-transform: uppercase; | |
| letter-spacing: .06em; | |
| } | |
| main { | |
| flex: 1; | |
| display: grid; | |
| grid-template-columns: 1fr 360px; | |
| gap: 1rem; | |
| padding: 1rem; | |
| max-width: 1400px; | |
| width: 100%; | |
| margin: 0 auto; | |
| } | |
| /* ββ Drop / canvas panel ββ */ | |
| .canvas-panel { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| position: relative; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-height: 480px; | |
| } | |
| #drop-zone { | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 1rem; | |
| cursor: pointer; | |
| transition: background .2s; | |
| z-index: 1; | |
| } | |
| #drop-zone.drag-over { background: rgba(59,130,246,.08); } | |
| #drop-zone.hidden { display: none; } | |
| .drop-icon { | |
| width: 72px; | |
| height: 72px; | |
| border-radius: 50%; | |
| background: rgba(59,130,246,.12); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| } | |
| .drop-icon svg { color: var(--accent); } | |
| #drop-zone p { color: var(--muted); font-size: .9rem; } | |
| #drop-zone b { color: var(--text); } | |
| #file-input { display: none; } | |
| /* canvas lives here */ | |
| #canvas-wrap { | |
| position: relative; | |
| display: none; | |
| width: 100%; | |
| height: 100%; | |
| } | |
| #canvas-wrap.visible { display: block; } | |
| #base-canvas, #overlay-canvas { | |
| position: absolute; | |
| top: 50%; | |
| left: 50%; | |
| transform: translate(-50%, -50%); | |
| } | |
| #overlay-canvas { pointer-events: none; } | |
| .canvas-toolbar { | |
| position: absolute; | |
| top: .75rem; | |
| right: .75rem; | |
| display: flex; | |
| gap: .5rem; | |
| z-index: 10; | |
| } | |
| .canvas-toolbar button { | |
| padding: .35rem .75rem; | |
| border-radius: 6px; | |
| border: 1px solid var(--border); | |
| background: rgba(15,17,23,.8); | |
| color: var(--text); | |
| font-size: .8rem; | |
| cursor: pointer; | |
| backdrop-filter: blur(4px); | |
| transition: border-color .15s; | |
| } | |
| .canvas-toolbar button:hover { border-color: var(--accent); } | |
| /* ββ Spinner overlay ββ */ | |
| #spinner { | |
| position: absolute; | |
| inset: 0; | |
| background: rgba(15,17,23,.7); | |
| display: none; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 1rem; | |
| z-index: 20; | |
| border-radius: var(--radius); | |
| } | |
| #spinner.active { display: flex; } | |
| .spin-ring { | |
| width: 48px; | |
| height: 48px; | |
| border: 3px solid var(--border); | |
| border-top-color: var(--accent); | |
| border-radius: 50%; | |
| animation: spin .8s linear infinite; | |
| } | |
| @keyframes spin { to { transform: rotate(360deg); } } | |
| /* ββ Results panel ββ */ | |
| .results-panel { | |
| display: flex; | |
| flex-direction: column; | |
| gap: .75rem; | |
| overflow-y: auto; | |
| max-height: calc(100vh - 100px); | |
| } | |
| .results-header { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: .75rem 1rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| } | |
| .results-header h2 { font-size: .95rem; font-weight: 600; } | |
| .timing-bar { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: .65rem 1rem; | |
| display: none; | |
| gap: 1rem; | |
| flex-wrap: wrap; | |
| } | |
| .timing-bar.visible { display: flex; } | |
| .timing-item { display: flex; flex-direction: column; gap: 2px; } | |
| .timing-label { font-size: .65rem; color: var(--muted); text-transform: uppercase; letter-spacing: .06em; } | |
| .timing-value { font-size: .85rem; font-weight: 600; font-variant-numeric: tabular-nums; } | |
| .no-results { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: 2rem 1rem; | |
| text-align: center; | |
| color: var(--muted); | |
| font-size: .9rem; | |
| } | |
| .fish-card { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| overflow: hidden; | |
| transition: border-color .15s; | |
| } | |
| .fish-card:hover { border-color: var(--accent); } | |
| .fish-card.highlighted { border-color: var(--accent2); } | |
| .fish-card-header { | |
| padding: .6rem .9rem; | |
| display: flex; | |
| align-items: center; | |
| gap: .5rem; | |
| cursor: pointer; | |
| background: rgba(255,255,255,.02); | |
| } | |
| .fish-number { | |
| width: 22px; | |
| height: 22px; | |
| border-radius: 50%; | |
| background: var(--accent); | |
| color: #fff; | |
| font-size: .7rem; | |
| font-weight: 700; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| flex-shrink: 0; | |
| } | |
| .fish-card-header h3 { font-size: .85rem; font-weight: 600; flex: 1; } | |
| .conf-badge { | |
| font-size: .7rem; | |
| padding: 2px 6px; | |
| border-radius: 4px; | |
| background: rgba(59,130,246,.15); | |
| color: var(--accent); | |
| } | |
| .fish-card-body { padding: .6rem .9rem .9rem; } | |
| .prediction-row { | |
| display: flex; | |
| align-items: center; | |
| gap: .5rem; | |
| padding: .4rem 0; | |
| border-bottom: 1px solid var(--border); | |
| } | |
| .prediction-row:last-child { border-bottom: none; } | |
| .pred-rank { | |
| width: 16px; | |
| font-size: .7rem; | |
| color: var(--muted); | |
| text-align: center; | |
| flex-shrink: 0; | |
| } | |
| .pred-name { flex: 1; font-size: .83rem; display: flex; flex-direction: column; gap: 1px; } | |
| .pred-taxon { font-size: .7rem; color: var(--muted); font-style: italic; } | |
| .pred-bar-wrap { width: 72px; background: var(--bg); border-radius: 4px; height: 6px; flex-shrink: 0; } | |
| .pred-bar { height: 100%; border-radius: 4px; background: var(--accent2); } | |
| .pred-pct { font-size: .75rem; color: var(--muted); width: 38px; text-align: right; flex-shrink: 0; } | |
| .bbox-info { | |
| margin-top: .5rem; | |
| font-size: .72rem; | |
| color: var(--muted); | |
| } | |
| /* Legend */ | |
| .legend { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: var(--radius); | |
| padding: .65rem 1rem; | |
| display: flex; | |
| gap: 1rem; | |
| align-items: center; | |
| font-size: .78rem; | |
| color: var(--muted); | |
| flex-wrap: wrap; | |
| } | |
| .legend-item { display: flex; align-items: center; gap: .4rem; } | |
| .legend-swatch { width: 14px; height: 4px; border-radius: 2px; } | |
| @media (max-width: 800px) { | |
| main { grid-template-columns: 1fr; } | |
| .results-panel { max-height: none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M6.5 12c.94-3.46 4.94-6 10.5-6s9.56 2.54 10.5 6c-.94 3.46-4.94 6-10.5 6S7.44 15.46 6.5 12z"/> | |
| <circle cx="17" cy="12" r="2"/> | |
| </svg> | |
| <h1>SW Identifier</h1> | |
| <span class="badge">Fish ID</span> | |
| </header> | |
| <main> | |
| <!-- ββ Left: canvas ββ --> | |
| <div class="canvas-panel" id="canvas-panel"> | |
| <div id="drop-zone"> | |
| <div class="drop-icon"> | |
| <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> | |
| <polyline points="16 16 12 12 8 16"/> | |
| <line x1="12" y1="12" x2="12" y2="21"/> | |
| <path d="M20.39 18.39A5 5 0 0 0 18 9h-1.26A8 8 0 1 0 3 16.3"/> | |
| </svg> | |
| </div> | |
| <p><b>Drop an image</b> here or <b id="browse-link" style="color:var(--accent);cursor:pointer">browse</b></p> | |
| <p>JPG, PNG, WEBP β any resolution</p> | |
| </div> | |
| <div id="canvas-wrap"> | |
| <canvas id="base-canvas"></canvas> | |
| <canvas id="overlay-canvas"></canvas> | |
| <div class="canvas-toolbar"> | |
| <button id="toggle-overlay" title="Toggle detection overlay">Overlay</button> | |
| <button id="new-image-btn">New image</button> | |
| </div> | |
| </div> | |
| <div id="spinner"> | |
| <div class="spin-ring"></div> | |
| <span style="color:var(--muted);font-size:.85rem">Analysingβ¦</span> | |
| </div> | |
| </div> | |
| <!-- ββ Right: results ββ --> | |
| <div class="results-panel"> | |
| <div class="results-header"> | |
| <h2>Detections</h2> | |
| <span id="detection-count" style="font-size:.8rem;color:var(--muted)">β</span> | |
| </div> | |
| <div class="timing-bar" id="timing-bar"> | |
| <div class="timing-item"> | |
| <span class="timing-label">Detect</span> | |
| <span class="timing-value" id="t-detect">β</span> | |
| </div> | |
| <div class="timing-item"> | |
| <span class="timing-label">Segment</span> | |
| <span class="timing-value" id="t-segment">β</span> | |
| </div> | |
| <div class="timing-item"> | |
| <span class="timing-label">Classify</span> | |
| <span class="timing-value" id="t-classify">β</span> | |
| </div> | |
| <div class="timing-item"> | |
| <span class="timing-label">Total</span> | |
| <span class="timing-value" id="t-total">β</span> | |
| </div> | |
| </div> | |
| <div id="results-body"> | |
| <div class="no-results">Drop an image to begin</div> | |
| </div> | |
| <div class="legend" id="legend" style="display:none"> | |
| <div class="legend-item"> | |
| <div class="legend-swatch" style="background:#3b82f6;height:2px;border:1px solid #3b82f6"></div> | |
| Bounding box | |
| </div> | |
| <div class="legend-item"> | |
| <div class="legend-swatch" style="background:rgba(16,185,129,.5)"></div> | |
| Segmentation | |
| </div> | |
| </div> | |
| </div> | |
| </main> | |
| <input type="file" id="file-input" accept="image/*" /> | |
| <script> | |
| (() => { | |
| const dropZone = document.getElementById('drop-zone'); | |
| const canvasWrap = document.getElementById('canvas-wrap'); | |
| const baseCanvas = document.getElementById('base-canvas'); | |
| const overlayCanvas = document.getElementById('overlay-canvas'); | |
| const spinner = document.getElementById('spinner'); | |
| const fileInput = document.getElementById('file-input'); | |
| const resultsBody = document.getElementById('results-body'); | |
| const timingBar = document.getElementById('timing-bar'); | |
| const countEl = document.getElementById('detection-count'); | |
| const legend = document.getElementById('legend'); | |
| const panel = document.getElementById('canvas-panel'); | |
| const baseCtx = baseCanvas.getContext('2d'); | |
| const overlayCtx = overlayCanvas.getContext('2d'); | |
| let currentDetections = []; | |
| let overlayVisible = true; | |
| let imgNaturalW = 0, imgNaturalH = 0; | |
| let displayW = 0, displayH = 0; | |
| let scaleX = 1, scaleY = 1; | |
| // ββ Drag & drop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| dropZone.addEventListener('dragover', e => { | |
| e.preventDefault(); | |
| dropZone.classList.add('drag-over'); | |
| }); | |
| dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over')); | |
| dropZone.addEventListener('drop', e => { | |
| e.preventDefault(); | |
| dropZone.classList.remove('drag-over'); | |
| const file = e.dataTransfer.files[0]; | |
| if (file && file.type.startsWith('image/')) processFile(file); | |
| }); | |
| document.getElementById('browse-link').addEventListener('click', () => fileInput.click()); | |
| dropZone.addEventListener('click', () => fileInput.click()); | |
| fileInput.addEventListener('change', () => { | |
| if (fileInput.files[0]) processFile(fileInput.files[0]); | |
| }); | |
| document.getElementById('toggle-overlay').addEventListener('click', () => { | |
| overlayVisible = !overlayVisible; | |
| overlayCanvas.style.display = overlayVisible ? '' : 'none'; | |
| }); | |
| document.getElementById('new-image-btn').addEventListener('click', reset); | |
| // Paste support | |
| document.addEventListener('paste', e => { | |
| const items = e.clipboardData?.items; | |
| if (!items) return; | |
| for (const item of items) { | |
| if (item.type.startsWith('image/')) { | |
| processFile(item.getAsFile()); | |
| break; | |
| } | |
| } | |
| }); | |
| // ββ Core flow βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| async function processFile(file) { | |
| reset(); | |
| const objectUrl = URL.createObjectURL(file); | |
| const img = new Image(); | |
| img.onload = async () => { | |
| renderImage(img); | |
| URL.revokeObjectURL(objectUrl); | |
| await runPipeline(file); | |
| }; | |
| img.src = objectUrl; | |
| } | |
| function renderImage(img) { | |
| imgNaturalW = img.naturalWidth; | |
| imgNaturalH = img.naturalHeight; | |
| // Fit image into panel keeping aspect ratio | |
| const panelW = panel.clientWidth - 2; // minus borders | |
| const panelH = panel.clientHeight - 2; | |
| const ratio = Math.min(panelW / imgNaturalW, panelH / imgNaturalH, 1); | |
| displayW = Math.round(imgNaturalW * ratio); | |
| displayH = Math.round(imgNaturalH * ratio); | |
| scaleX = displayW / imgNaturalW; | |
| scaleY = displayH / imgNaturalH; | |
| for (const c of [baseCanvas, overlayCanvas]) { | |
| c.width = displayW; | |
| c.height = displayH; | |
| c.style.width = displayW + 'px'; | |
| c.style.height = displayH + 'px'; | |
| } | |
| baseCtx.drawImage(img, 0, 0, displayW, displayH); | |
| dropZone.classList.add('hidden'); | |
| canvasWrap.classList.add('visible'); | |
| } | |
| async function runPipeline(file) { | |
| spinner.classList.add('active'); | |
| const form = new FormData(); | |
| form.append('file', file); | |
| try { | |
| const resp = await fetch('/predict', { method: 'POST', body: form }); | |
| if (!resp.ok) { | |
| const err = await resp.json().catch(() => ({ detail: resp.statusText })); | |
| throw new Error(err.detail || resp.statusText); | |
| } | |
| const data = await resp.json(); | |
| currentDetections = data.detections; | |
| renderOverlay(data.detections); | |
| renderResults(data); | |
| } catch (err) { | |
| resultsBody.innerHTML = `<div class="no-results" style="color:#f87171">Error: ${err.message}</div>`; | |
| } finally { | |
| spinner.classList.remove('active'); | |
| } | |
| } | |
| // ββ Canvas overlay ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderOverlay(detections) { | |
| overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); | |
| detections.forEach((det, idx) => { | |
| const { bbox, polygon } = det; | |
| const color = hue(idx); | |
| // Segmentation polygon fill | |
| if (polygon && polygon.length > 2) { | |
| overlayCtx.beginPath(); | |
| overlayCtx.moveTo(polygon[0][0] * scaleX, polygon[0][1] * scaleY); | |
| for (let i = 1; i < polygon.length; i++) { | |
| overlayCtx.lineTo(polygon[i][0] * scaleX, polygon[i][1] * scaleY); | |
| } | |
| overlayCtx.closePath(); | |
| overlayCtx.fillStyle = color.fill; | |
| overlayCtx.fill(); | |
| overlayCtx.strokeStyle = color.stroke; | |
| overlayCtx.lineWidth = 1.5; | |
| overlayCtx.stroke(); | |
| } | |
| // Bounding box | |
| const bx1 = bbox.x1 * scaleX; | |
| const by1 = bbox.y1 * scaleY; | |
| const bw = (bbox.x2 - bbox.x1) * scaleX; | |
| const bh = (bbox.y2 - bbox.y1) * scaleY; | |
| overlayCtx.strokeStyle = '#3b82f6'; | |
| overlayCtx.lineWidth = 2; | |
| overlayCtx.strokeRect(bx1, by1, bw, bh); | |
| // Label chip | |
| const topName = det.predictions[0]?.name || '?'; | |
| const topConf = det.predictions[0]?.accuracy ?? 0; | |
| const label = `#${idx + 1} ${topName} ${(topConf * 100).toFixed(0)}%`; | |
| const fontSize = Math.max(10, Math.round(11 * Math.min(scaleX, scaleY))); | |
| overlayCtx.font = `600 ${fontSize}px -apple-system, sans-serif`; | |
| const tw = overlayCtx.measureText(label).width; | |
| const pad = 4; | |
| const chipH = fontSize + pad * 2; | |
| const chipY = Math.max(0, by1 - chipH - 2); | |
| overlayCtx.fillStyle = '#3b82f6'; | |
| roundRect(overlayCtx, bx1, chipY, tw + pad * 2, chipH, 4); | |
| overlayCtx.fill(); | |
| overlayCtx.fillStyle = '#fff'; | |
| overlayCtx.fillText(label, bx1 + pad, chipY + chipH - pad - 1); | |
| }); | |
| } | |
| function roundRect(ctx, x, y, w, h, r) { | |
| ctx.beginPath(); | |
| ctx.moveTo(x + r, y); | |
| ctx.lineTo(x + w - r, y); | |
| ctx.quadraticCurveTo(x + w, y, x + w, y + r); | |
| ctx.lineTo(x + w, y + h - r); | |
| ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); | |
| ctx.lineTo(x + r, y + h); | |
| ctx.quadraticCurveTo(x, y + h, x, y + h - r); | |
| ctx.lineTo(x, y + r); | |
| ctx.quadraticCurveTo(x, y, x + r, y); | |
| ctx.closePath(); | |
| } | |
| const PALETTE = [ | |
| { fill: 'rgba(16,185,129,.25)', stroke: '#10b981' }, | |
| { fill: 'rgba(245,158,11,.25)', stroke: '#f59e0b' }, | |
| { fill: 'rgba(239,68,68,.25)', stroke: '#ef4444' }, | |
| { fill: 'rgba(168,85,247,.25)', stroke: '#a855f7' }, | |
| { fill: 'rgba(236,72,153,.25)', stroke: '#ec4899' }, | |
| ]; | |
| function hue(i) { return PALETTE[i % PALETTE.length]; } | |
| // ββ Results panel βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| function renderResults(data) { | |
| const { detections, timing } = data; | |
| // Timing bar | |
| document.getElementById('t-detect').textContent = timing.detect_ms + ' ms'; | |
| document.getElementById('t-segment').textContent = timing.segment_ms + ' ms'; | |
| document.getElementById('t-classify').textContent = timing.classify_ms + ' ms'; | |
| document.getElementById('t-total').textContent = timing.total_ms + ' ms'; | |
| timingBar.classList.add('visible'); | |
| countEl.textContent = detections.length | |
| ? `${detections.length} fish found` | |
| : 'No fish detected'; | |
| legend.style.display = detections.length ? '' : 'none'; | |
| if (!detections.length) { | |
| resultsBody.innerHTML = '<div class="no-results">No fish detected in this image</div>'; | |
| return; | |
| } | |
| resultsBody.innerHTML = ''; | |
| detections.forEach((det, idx) => { | |
| const card = document.createElement('div'); | |
| card.className = 'fish-card'; | |
| card.dataset.idx = idx; | |
| const topName = det.predictions[0]?.name || 'Unknown'; | |
| const detConf = (det.bbox.confidence * 100).toFixed(0); | |
| card.innerHTML = ` | |
| <div class="fish-card-header"> | |
| <div class="fish-number">${idx + 1}</div> | |
| <h3>${esc(topName)}</h3> | |
| <span class="conf-badge">det ${detConf}%</span> | |
| </div> | |
| <div class="fish-card-body"> | |
| ${det.predictions.length | |
| ? det.predictions.map((p, r) => predRow(p, r)).join('') | |
| : '<span style="color:var(--muted);font-size:.8rem">No classification</span>' | |
| } | |
| <div class="bbox-info"> | |
| Box: ${det.bbox.x1},${det.bbox.y1} β ${det.bbox.x2},${det.bbox.y2} | |
| Β· | |
| ${det.bbox.x2 - det.bbox.x1}Γ${det.bbox.y2 - det.bbox.y1} px | |
| ${det.polygon ? ' Β· seg β' : ''} | |
| </div> | |
| </div>`; | |
| card.querySelector('.fish-card-header').addEventListener('mouseenter', () => { | |
| card.classList.add('highlighted'); | |
| highlightDetection(idx); | |
| }); | |
| card.querySelector('.fish-card-header').addEventListener('mouseleave', () => { | |
| card.classList.remove('highlighted'); | |
| renderOverlay(currentDetections); | |
| }); | |
| resultsBody.appendChild(card); | |
| }); | |
| } | |
| function predRow(p, rank) { | |
| const pct = (p.accuracy * 100).toFixed(1); | |
| const bar = Math.round(p.accuracy * 100); | |
| return `<div class="prediction-row"> | |
| <span class="pred-rank">${rank + 1}</span> | |
| <span class="pred-name">${esc(p.name)}<span class="pred-taxon">${esc(p.taxon)}</span></span> | |
| <div class="pred-bar-wrap"><div class="pred-bar" style="width:${bar}%"></div></div> | |
| <span class="pred-pct">${pct}%</span> | |
| </div>`; | |
| } | |
| function highlightDetection(idx) { | |
| renderOverlay(currentDetections); | |
| // draw a brighter ring around the selected detection | |
| const det = currentDetections[idx]; | |
| if (!det) return; | |
| const { bbox } = det; | |
| overlayCtx.strokeStyle = '#facc15'; | |
| overlayCtx.lineWidth = 3; | |
| overlayCtx.strokeRect( | |
| bbox.x1 * scaleX - 2, | |
| bbox.y1 * scaleY - 2, | |
| (bbox.x2 - bbox.x1) * scaleX + 4, | |
| (bbox.y2 - bbox.y1) * scaleY + 4, | |
| ); | |
| } | |
| function reset() { | |
| currentDetections = []; | |
| overlayVisible = true; | |
| overlayCanvas.style.display = ''; | |
| overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height); | |
| baseCtx.clearRect(0, 0, baseCanvas.width, baseCanvas.height); | |
| canvasWrap.classList.remove('visible'); | |
| dropZone.classList.remove('hidden'); | |
| resultsBody.innerHTML = '<div class="no-results">Drop an image to begin</div>'; | |
| timingBar.classList.remove('visible'); | |
| countEl.textContent = 'β'; | |
| legend.style.display = 'none'; | |
| fileInput.value = ''; | |
| } | |
| function esc(s) { | |
| return String(s) | |
| .replace(/&/g, '&') | |
| .replace(/</g, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"'); | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |