| <!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; |
| |
| |
| 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); |
| } |
| } |
| |
| |
| let offscreen = null; |
| let offCtx = null; |
| |
| |
| 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'; |
| |
| |
| ctx.strokeStyle = color; |
| ctx.lineWidth = 3; |
| ctx.strokeRect(xmin, ymin, width, height); |
| |
| |
| 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); |
| |
| |
| 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> |
|
|