SWClassifier / static /index.html
tcooper-xx's picture
Initial Commit
34ecf0d
<!DOCTYPE html>
<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}
&nbsp;Β·&nbsp;
${det.bbox.x2 - det.bbox.x1}Γ—${det.bbox.y2 - det.bbox.y1} px
${det.polygon ? '&nbsp;Β·&nbsp;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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
})();
</script>
</body>
</html>