babelbox / index.html
malen-h's picture
Add index.html and worker.js for RF-DETR object detection interface
3c9e0f6
Raw
History Blame Contribute Delete
27.1 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Edge Vision Translator</title>
<style>
:root {
--bg-color: #0f1115;
--surface: #1e2128;
--surface-strong: #151922;
--text-main: #f8f9fa;
--text-muted: #8b92a5;
--accent-blue: #007bff;
--accent-green: #38b24a;
--accent-orange: #ff8a3d;
--border-radius: 8px;
}
body {
margin: 0;
padding: 0;
background-color: var(--bg-color);
color: var(--text-main);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
}
header {
width: 100%;
padding: 0 40px;
box-sizing: border-box;
border-bottom: 1px solid rgba(255, 255, 255, 0.14);
display: flex;
justify-content: space-between;
align-items: center;
height: 72px;
}
.tabs {
display: flex;
align-self: stretch;
}
.tab {
display: flex;
align-items: center;
padding: 0 26px;
color: var(--text-muted);
border-bottom: 3px solid transparent;
font-size: 18px;
font-weight: 700;
}
.tab.active {
color: var(--text-main);
border-bottom-color: var(--accent-orange);
}
.stage {
width: min(960px, calc(100vw - 48px));
margin-top: 30px;
}
.controls {
display: flex;
align-items: center;
gap: 14px;
}
.header-control {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.controls select {
background-color: var(--surface);
color: var(--text-main);
border: 1px solid rgba(255,255,255,0.1);
padding: 8px 16px;
border-radius: var(--border-radius);
font-size: 14px;
outline: none;
cursor: pointer;
}
.video-container {
position: relative;
border-radius: var(--border-radius);
overflow: hidden;
background-color: #000;
border: 1px solid rgba(255, 255, 255, 0.12);
padding-top: 50px;
}
.stream-bar {
position: absolute;
left: 0;
right: 0;
top: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(21, 25, 34, 0.92);
height: 50px;
padding: 0 18px;
box-sizing: border-box;
font-size: 16px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
}
.stream-controls {
display: flex;
align-items: center;
gap: 14px;
min-height: 70px;
margin-top: 18px;
padding: 0 22px;
border-radius: var(--border-radius);
box-sizing: border-box;
background: var(--surface-strong);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.control-group {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 12px;
white-space: nowrap;
}
.control-group output {
min-width: 34px;
padding: 3px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.08);
color: var(--text-main);
text-align: center;
}
input[type="range"] {
width: 120px;
accent-color: var(--accent-blue);
}
.toggle {
width: 34px;
height: 18px;
appearance: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.18);
position: relative;
cursor: pointer;
}
.toggle::after {
content: "";
position: absolute;
width: 14px;
height: 14px;
top: 2px;
left: 2px;
border-radius: 50%;
background: #ffffff;
transition: transform 0.15s ease;
}
.toggle:checked {
background: var(--accent-orange);
}
.toggle:checked::after {
transform: translateX(16px);
}
.stream-stat {
margin-left: auto;
color: var(--text-muted);
font-size: 14px;
}
.stream-stat strong {
color: var(--accent-green);
}
video {
display: block;
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
}
canvas {
position: absolute;
top: 50px;
left: 0;
width: 100%;
height: calc(100% - 50px);
pointer-events: none;
}
#status {
margin-top: 16px;
color: var(--text-muted);
font-size: 14px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.progress-container {
width: 300px;
height: 4px;
background: rgba(255,255,255,0.1);
border-radius: 2px;
overflow: hidden;
display: none;
}
#progress-bar {
width: 0%;
height: 100%;
background: var(--accent-blue);
transition: width 0.2s ease;
}
.actions {
display: flex;
align-items: center;
gap: 14px;
margin-top: 18px;
}
button {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
padding: 14px 24px;
color: var(--text-main);
background: var(--surface);
font-size: 15px;
font-weight: 700;
cursor: pointer;
}
#start-button {
background: #8f542d;
}
.run-status {
margin-left: auto;
color: var(--text-muted);
font-size: 16px;
}
.run-status::before {
content: "";
display: inline-block;
width: 9px;
height: 9px;
margin-right: 8px;
border-radius: 50%;
background: var(--accent-green);
}
</style>
</head>
<body>
<header>
<nav class="tabs">
<div class="tab active">Realtime Webcam</div>
<div class="tab">Upload Images & Videos</div>
</nav>
<div class="controls">
<label class="header-control">
<span>Engine</span>
<select id="detector-mode">
<option value="backend-rfdetr">RF-DETR backend</option>
<option value="browser-detr">Legacy browser DETR</option>
</select>
</label>
<label class="header-control">
<span>Language</span>
<select id="target-language">
<option value="spa_Latn">Spanish (spa_Latn)</option>
<option value="fra_Latn">French (fra_Latn)</option>
<option value="zho_Hans">Chinese Simplified (zho_Hans)</option>
<option value="hin_Deva">Hindi (hin_Deva)</option>
<option value="afr_Latn">Afrikaans (afr_Latn)</option>
<option value="zul_Latn">Zulu (zul_Latn)</option>
</select>
</label>
</div>
</header>
<main class="stage">
<div class="video-container">
<div class="stream-bar">
<span>Live stream</span>
<span id="stream-state">loading</span>
</div>
<video id="webcam" autoplay playsinline muted></video>
<canvas id="overlay"></canvas>
</div>
<div class="stream-controls">
<label class="control-group">
<input class="toggle" id="focus-mode" type="checkbox" checked>
<span>Focus object</span>
</label>
<label class="control-group">
<span>Confidence</span>
<input id="confidence" type="range" min="0.3" max="0.95" step="0.05" value="0.75">
<output id="confidence-value">0.75</output>
</label>
<div class="stream-stat"><strong id="result-count">0</strong> labels</div>
</div>
<div class="actions">
<button id="start-button" type="button">Start streaming</button>
<button id="stop-button" type="button">Stop</button>
<div class="run-status" id="run-status">Loading model</div>
</div>
<div id="status">
<div id="status-text">Initializing vision model...</div>
<div class="progress-container" id="progress-container">
<div id="progress-bar"></div>
</div>
</div>
</main>
<script>
const video = document.getElementById('webcam');
const canvas = document.getElementById('overlay');
const ctx = canvas.getContext('2d');
const statusText = document.getElementById('status-text');
const progressContainer = document.getElementById('progress-container');
const progressBar = document.getElementById('progress-bar');
const detectorModeSelect = document.getElementById('detector-mode');
const languageSelect = document.getElementById('target-language');
const confidenceInput = document.getElementById('confidence');
const confidenceValue = document.getElementById('confidence-value');
const focusMode = document.getElementById('focus-mode');
const resultCount = document.getElementById('result-count');
const streamState = document.getElementById('stream-state');
const runStatus = document.getElementById('run-status');
const startButton = document.getElementById('start-button');
const stopButton = document.getElementById('stop-button');
const workerUrl = './worker.js';
const backendUrl = location.port === '8000' || location.protocol === 'file:'
? 'http://127.0.0.1:7860/detect'
: '/detect';
const labelTranslations = {
spa_Latn: {
person: 'persona',
bottle: 'botella',
cup: 'taza',
book: 'libro',
cell_phone: 'telefono movil',
'cell phone': 'telefono movil',
laptop: 'portatil',
chair: 'silla',
tv: 'televisor',
keyboard: 'teclado',
mouse: 'raton',
remote: 'control remoto',
apple: 'manzana',
banana: 'platano',
orange: 'naranja',
bowl: 'tazon',
spoon: 'cuchara',
fork: 'tenedor',
knife: 'cuchillo'
},
fra_Latn: {
person: 'personne',
bottle: 'bouteille',
cup: 'tasse',
book: 'livre',
cell_phone: 'telephone portable',
'cell phone': 'telephone portable',
laptop: 'ordinateur portable',
chair: 'chaise',
tv: 'television',
keyboard: 'clavier',
mouse: 'souris',
remote: 'telecommande',
apple: 'pomme',
banana: 'banane',
orange: 'orange',
bowl: 'bol',
spoon: 'cuillere',
fork: 'fourchette',
knife: 'couteau'
},
zho_Hans: {
person: 'ren',
bottle: 'pingzi',
cup: 'beizi',
book: 'shu',
laptop: 'bijibendiannao',
chair: 'yizi',
tv: 'dianshi',
keyboard: 'jianpan',
mouse: 'shubiao',
apple: 'pingguo',
banana: 'xiangjiao',
orange: 'chengzi',
bowl: 'wan',
spoon: 'shaozi',
fork: 'chazi',
knife: 'dao'
},
afr_Latn: {
person: 'persoon',
bottle: 'bottel',
cup: 'koppie',
book: 'boek',
laptop: 'skootrekenaar',
chair: 'stoel',
tv: 'televisie',
keyboard: 'sleutelbord',
mouse: 'muis',
apple: 'appel',
banana: 'piesang',
orange: 'lemoen',
bowl: 'bak',
spoon: 'lepel',
fork: 'vurk',
knife: 'mes'
},
zul_Latn: {
person: 'umuntu',
bottle: 'ibhodlela',
cup: 'inkomishi',
book: 'incwadi',
laptop: 'ikhompyutha ephathwayo',
chair: 'isitulo',
tv: 'ithelevishini',
keyboard: 'ikhibhodi',
mouse: 'igundane',
apple: 'i-apula',
banana: 'ubhanana',
orange: 'iwolintshi',
bowl: 'isitsha',
spoon: 'isipuni',
fork: 'imfoloko',
knife: 'ummese'
},
hin_Deva: {
person: 'vyakti',
bottle: 'botal',
cup: 'cup',
book: 'kitaab',
laptop: 'laptop',
chair: 'kursi',
tv: 'TV',
keyboard: 'keyboard',
mouse: 'mouse',
apple: 'seb',
banana: 'kela',
orange: 'santra',
bowl: 'katora',
spoon: 'chammach',
fork: 'kanta',
knife: 'chaku'
}
};
let isModelReady = false;
let isStreaming = true;
let detectorMode = detectorModeSelect.value;
let detectorWorker = null;
let pendingWorkerDetection = null;
let runId = 0;
// Initialize WebRTC Camera
async function setupCamera() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { width: 640, height: 480, facingMode: 'environment' }
});
video.srcObject = stream;
return new Promise((resolve) => {
video.onloadedmetadata = () => {
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
resolve(video);
};
});
} catch (e) {
statusText.innerText = "Camera access denied or unavailable.";
console.error(e);
}
}
// Global offscreen canvas for reuse to prevent GC thrashing
let offscreen = null;
let offCtx = null;
// Refresh labels when language changes.
languageSelect.addEventListener('change', (e) => {
if (detectorWorker) {
detectorWorker.postMessage({ type: 'set-language', language: e.target.value });
}
drawBoundingBoxes([]);
});
confidenceInput.addEventListener('input', (e) => {
confidenceValue.value = Number(e.target.value).toFixed(2);
if (detectorWorker) {
detectorWorker.postMessage({ type: 'set-threshold', threshold: Number(e.target.value) });
}
});
focusMode.addEventListener('change', (e) => {
if (detectorWorker) {
detectorWorker.postMessage({ type: 'set-focus-mode', enabled: e.target.checked });
}
drawBoundingBoxes([]);
});
startButton.addEventListener('click', () => {
if (!isModelReady) return;
if (isStreaming) return;
isStreaming = true;
streamState.innerText = "streaming";
runStatus.innerText = "Streaming";
processFrame(++runId);
});
stopButton.addEventListener('click', () => {
if (!isStreaming) return;
isStreaming = false;
runId++;
streamState.innerText = "paused";
runStatus.innerText = "Paused";
});
detectorModeSelect.addEventListener('change', async (e) => {
const shouldResume = isStreaming;
detectorMode = e.target.value;
isStreaming = false;
runId++;
drawBoundingBoxes([]);
await initializeDetector(shouldResume);
});
async function processFrame(activeRunId = runId) {
if (!isModelReady || !isStreaming || !offCtx || activeRunId !== runId) return;
try {
offCtx.drawImage(video, 0, 0, offscreen.width, offscreen.height);
const image = await offscreen.convertToBlob({
type: 'image/jpeg',
quality: 0.8
});
const mode = detectorMode;
statusText.innerText = mode === 'backend-rfdetr'
? "RF-DETR backend scanning..."
: "Legacy browser DETR scanning...";
const results = mode === 'backend-rfdetr'
? await detectObjectsWithBackend(image)
: await detectObjectsWithWorker(image);
if (activeRunId !== runId || mode !== detectorMode) return;
drawBoundingBoxes(results);
runStatus.innerText = "Streaming";
} catch (err) {
console.error(err);
const isBackend = detectorMode === 'backend-rfdetr';
runStatus.innerText = isBackend ? "Backend unavailable" : "Browser model unavailable";
statusText.innerText = isBackend
? `Start the RF-DETR backend on port 7860. ${err.message}`
: `Legacy browser DETR failed. ${err.message}`;
drawBoundingBoxes([]);
} finally {
if (isStreaming && activeRunId === runId) {
requestAnimationFrame(() => processFrame(activeRunId));
}
}
}
async function detectObjectsWithBackend(image) {
const response = await fetch(backendUrl, {
method: 'POST',
headers: {
'Content-Type': 'image/jpeg',
'X-Confidence-Threshold': confidenceInput.value,
'X-Focus-Mode': String(focusMode.checked)
},
body: image
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error || 'Detection failed');
}
return payload.results.map((result) => ({
...result,
translated_label: translateLabel(result.label)
}));
}
async function detectObjectsWithWorker(image) {
if (!detectorWorker) {
throw new Error('Browser worker is not running');
}
if (pendingWorkerDetection) {
throw new Error('Browser worker is still processing the previous frame');
}
return new Promise((resolve, reject) => {
pendingWorkerDetection = {
resolve: (results) => resolve(results.map((result) => ({
...result,
translated_label: result.translated_label ?? translateLabel(result.label)
}))),
reject
};
detectorWorker.postMessage({
type: 'detect',
image,
language: languageSelect.value
});
});
}
function translateLabel(label) {
const normalized = label.toLowerCase().replaceAll('-', ' ');
return labelTranslations[languageSelect.value]?.[normalized] ?? label;
}
function drawBoundingBoxes(results) {
ctx.clearRect(0, 0, canvas.width, canvas.height);
resultCount.innerText = results.length;
results.forEach(result => {
const coords = Array.isArray(result.box)
? {
xmin: result.box[0],
ymin: result.box[1],
xmax: result.box[2],
ymax: result.box[3]
}
: result.box;
if (!coords) return;
const { xmin, ymin, xmax, ymax } = coords;
const width = xmax - xmin;
const height = ymax - ymin;
if (!Number.isFinite(width) || !Number.isFinite(height)) return;
const color = result.label === 'person' ? '#f05ca8' : '#38b24a';
// Slick styling: sharp color, clean professional text
ctx.strokeStyle = color;
ctx.lineWidth = 3;
ctx.strokeRect(xmin, ymin, width, height);
// Label Background
ctx.fillStyle = color;
const confidence = `${Math.round(result.score * 100)}%`;
const label = result.translated_label === result.label
? result.label
: `${result.label} -> ${result.translated_label}`;
const text = `${label} (${confidence})`;
const textWidth = ctx.measureText(text).width;
ctx.fillRect(xmin, ymin - 25, textWidth + 16, 25);
// Label Text
ctx.fillStyle = '#ffffff';
ctx.font = '14px -apple-system, sans-serif';
ctx.fillText(text, xmin + 8, ymin - 8);
});
}
function disposeDetectorWorker() {
if (pendingWorkerDetection) {
pendingWorkerDetection.reject(new Error('Detector engine changed'));
pendingWorkerDetection = null;
}
if (detectorWorker) {
detectorWorker.terminate();
detectorWorker = null;
}
}
async function initializeDetector(shouldStream) {
const activeRunId = ++runId;
isModelReady = false;
startButton.disabled = true;
progressContainer.style.display = 'none';
progressBar.style.width = '0%';
if (detectorMode === 'backend-rfdetr') {
disposeDetectorWorker();
isModelReady = true;
isStreaming = shouldStream;
startButton.disabled = false;
streamState.innerText = shouldStream ? "streaming" : "ready";
runStatus.innerText = shouldStream ? "Streaming" : "Ready";
statusText.innerText = "Using RF-DETR backend. Start backend.py on port 7860.";
if (shouldStream) {
processFrame(activeRunId);
}
return;
}
disposeDetectorWorker();
isStreaming = false;
streamState.innerText = "loading";
runStatus.innerText = "Loading browser model";
statusText.innerText = "Loading legacy browser DETR model...";
progressContainer.style.display = 'block';
detectorWorker = new Worker(workerUrl, { type: 'module' });
detectorWorker.postMessage({ type: 'set-language', language: languageSelect.value });
detectorWorker.postMessage({ type: 'set-threshold', threshold: Number(confidenceInput.value) });
detectorWorker.postMessage({ type: 'set-focus-mode', enabled: focusMode.checked });
detectorWorker.onmessage = (event) => {
const { type, data } = event.data;
if (type === 'status') {
if (data === 'ready') {
if (activeRunId !== runId) return;
isModelReady = true;
isStreaming = shouldStream;
startButton.disabled = false;
progressContainer.style.display = 'none';
streamState.innerText = shouldStream ? "streaming" : "ready";
runStatus.innerText = shouldStream ? "Streaming" : "Ready";
statusText.innerText = "Using legacy browser DETR.";
if (shouldStream) {
processFrame(activeRunId);
}
} else {
statusText.innerText = data;
}
}
if (type === 'results' && pendingWorkerDetection) {
const detection = pendingWorkerDetection;
pendingWorkerDetection = null;
detection.resolve(data);
}
};
detectorWorker.onerror = (event) => {
if (pendingWorkerDetection) {
pendingWorkerDetection.reject(new Error(event.message));
pendingWorkerDetection = null;
}
runStatus.innerText = "Browser model unavailable";
statusText.innerText = `Legacy browser DETR failed. ${event.message}`;
};
}
async function initializeApp() {
await setupCamera();
offscreen = new OffscreenCanvas(video.videoWidth, video.videoHeight);
offCtx = offscreen.getContext('2d');
await initializeDetector(true);
}
initializeApp();
</script>
</body>
</html>