| |
| |
| |
|
|
| import { AutoModel, AutoProcessor, RawImage } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1'; |
|
|
| |
| const video = document.getElementById('webcam'); |
| const canvas = document.getElementById('canvas'); |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); |
| const robotSvg = document.getElementById('robot-svg'); |
| const cameraBtn = document.getElementById('camera-btn'); |
| const cameraIcon = document.getElementById('camera-icon'); |
| const cameraText = document.getElementById('camera-text'); |
| const startBtn = document.getElementById('start-btn'); |
| const btnIcon = document.getElementById('btn-icon'); |
| const btnText = document.getElementById('btn-text'); |
| const statusIndicator = document.getElementById('status-indicator'); |
| const statusText = document.getElementById('status-text'); |
| const fpsEl = document.getElementById('fps'); |
| const responseText = document.getElementById('response-text'); |
| const loader = document.getElementById('loader'); |
| const loaderText = document.getElementById('loader-text'); |
|
|
| |
| const showLoader = (text) => { |
| loaderText.textContent = text; |
| loader.classList.add('visible'); |
| }; |
|
|
| const hideLoader = () => { |
| loader.classList.remove('visible'); |
| }; |
|
|
| |
| const DEMO_COOLDOWN = 10; |
| const DEMO_PRAISE_ENABLED = true; |
|
|
| |
| let model = null; |
| let processor = null; |
| let isRunning = false; |
| let isMonitoring = false; |
| let isProcessing = false; |
| let animationId = null; |
| let stream = null; |
|
|
| |
| let phoneVisible = false; |
| let consecutivePhone = 0; |
| let consecutiveNoPhone = 0; |
| let phoneCount = 0; |
| let lastReactionTime = 0; |
|
|
| |
| let lastPhoneBox = null; |
| let framesWithoutDetection = 0; |
|
|
| |
| const offscreen = document.createElement('canvas'); |
| const offscreenCtx = offscreen.getContext('2d', { willReadFrequently: true }); |
|
|
| |
| const smallCanvas = document.createElement('canvas'); |
| const smallCtx = smallCanvas.getContext('2d', { willReadFrequently: true }); |
|
|
| |
| const PHONE_CLASS_ID = 67; |
| const PICKUP_THRESHOLD = 3; |
| const PUTDOWN_THRESHOLD = 15; |
| const DETECTION_CONFIDENCE = 0.5; |
| const TRACKING_CONFIDENCE = 0.2; |
| const TRACKING_PERSIST_FRAMES = 3; |
|
|
| |
| const SHAME_EMOTIONS = [ |
| "disgusted1", |
| "resigned1", |
| "displeased1", |
| "displeased2", |
| "rage1", |
| "no1", |
| "reprimand1", |
| "reprimand3", |
| "dying1", |
| "surprised1", |
| "surprised2" |
| ]; |
|
|
| const PRAISE_EMOTIONS = [ |
| "welcoming2", |
| "inquiring1", |
| "inquiring2", |
| "proud1", |
| "proud3", |
| "success1", |
| "success2", |
| "enthusiastic1", |
| "enthusiastic2", |
| "grateful1", |
| "yes1", |
| "cheerful1" |
| ]; |
|
|
| const EMOTIONS_BASE_URL = "https://huggingface.co/datasets/pollen-robotics/reachy-mini-emotions-library/resolve/main"; |
|
|
| |
| async function init() { |
| try { |
| |
| cameraBtn.disabled = true; |
| startBtn.disabled = true; |
|
|
| |
| const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent); |
| const modelName = isMobile ? 'yolo26n-ONNX' : 'yolo26m-ONNX'; |
| const modelDisplay = isMobile ? 'YOLO26n (mobile-optimized)' : 'YOLO26m'; |
|
|
| |
| if (isMobile) { |
| console.log('Mobile detected - skipping model load'); |
| hideLoader(); |
| return; |
| } |
|
|
| |
| showLoader(`Loading ${modelDisplay} model...`); |
| statusText.textContent = 'Loading AI model...'; |
| statusIndicator.className = 'status-dot loading'; |
|
|
| |
| model = await AutoModel.from_pretrained(`onnx-community/${modelName}`, { |
| device: 'webgpu', |
| dtype: 'fp16' |
| }); |
|
|
| showLoader('Loading processor...'); |
| processor = await AutoProcessor.from_pretrained(`onnx-community/${modelName}`); |
|
|
| |
| hideLoader(); |
| statusText.textContent = 'Model ready! Open camera to begin'; |
| statusIndicator.className = 'status-dot ready'; |
| cameraBtn.disabled = false; |
|
|
| console.log('YOLO model loaded successfully'); |
| } catch (error) { |
| console.error('Failed to load model:', error); |
| showLoader('Failed to load model: ' + error.message); |
| statusText.textContent = 'Error loading model'; |
| statusIndicator.className = 'status-dot error'; |
| } |
| } |
|
|
| |
| async function startCamera() { |
| try { |
| stream = await navigator.mediaDevices.getUserMedia({ |
| video: { |
| width: 640, |
| height: 480, |
| facingMode: 'user' |
| } |
| }); |
|
|
| video.srcObject = stream; |
| await video.play(); |
|
|
| |
| video.style.display = 'block'; |
| canvas.style.display = 'block'; |
|
|
| canvas.width = offscreen.width = video.videoWidth; |
| canvas.height = offscreen.height = video.videoHeight; |
|
|
| isRunning = true; |
| loop(); |
|
|
| statusIndicator.className = isMonitoring ? 'status-dot monitoring' : 'status-dot ready'; |
|
|
| } catch (error) { |
| console.error('Camera error:', error); |
| alert('Could not access webcam. Please allow camera permissions.'); |
| } |
| } |
|
|
| |
| function stopCamera() { |
| isRunning = false; |
|
|
| if (stream) { |
| stream.getTracks().forEach(track => track.stop()); |
| stream = null; |
| } |
|
|
| if (animationId) { |
| cancelAnimationFrame(animationId); |
| } |
|
|
| |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| video.style.display = 'none'; |
| canvas.style.display = 'none'; |
| } |
|
|
| |
| function loop() { |
| if (!isRunning) return; |
|
|
| if (!isMonitoring) { |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| } |
|
|
| |
| if (isMonitoring && !isProcessing) { |
| isProcessing = true; |
| const startTime = performance.now(); |
| detectAndProcess() |
| .then(() => { |
| fpsEl.textContent = Math.round(1000 / (performance.now() - startTime)); |
| }) |
| .finally(() => { |
| isProcessing = false; |
| }); |
| } |
|
|
| if (isRunning) animationId = requestAnimationFrame(loop); |
| } |
|
|
| |
| async function detectAndProcess() { |
| |
| const detections = await detectPhoneAndGetBoxes(); |
|
|
| |
| const phoneInFrame = detections.length > 0; |
|
|
| |
| if (phoneInFrame) { |
| consecutivePhone++; |
| consecutiveNoPhone = 0; |
| } else { |
| consecutiveNoPhone++; |
| } |
|
|
| |
| if (consecutivePhone >= PICKUP_THRESHOLD && !phoneVisible) { |
| phoneVisible = true; |
| consecutiveNoPhone = 0; |
|
|
| const now = Date.now(); |
| const cooldown = DEMO_COOLDOWN * 1000; |
|
|
| if (now - lastReactionTime >= cooldown) { |
| phoneCount++; |
| lastReactionTime = now; |
| handlePhonePickup(); |
| } |
| } |
|
|
| |
| if (phoneVisible && phoneInFrame) { |
| const now = Date.now(); |
| const cooldown = DEMO_COOLDOWN * 1000; |
|
|
| if (now - lastReactionTime >= cooldown) { |
| phoneCount++; |
| lastReactionTime = now; |
| handlePhonePickup(); |
| } |
| } |
|
|
| |
| if (consecutiveNoPhone >= PUTDOWN_THRESHOLD && phoneVisible) { |
| phoneVisible = false; |
| consecutivePhone = 0; |
| lastReactionTime = 0; |
|
|
| |
| if (DEMO_PRAISE_ENABLED) { |
| handlePhonePutdown(); |
| } |
| } |
|
|
| |
| if (isMonitoring) { |
| if (phoneVisible) { |
| statusText.textContent = '📱 PHONE DETECTED!'; |
| statusIndicator.className = 'status-dot detected'; |
| } else { |
| statusText.textContent = '✅ Phone-free'; |
| statusIndicator.className = 'status-dot monitoring'; |
| } |
|
|
| |
| draw(detections); |
| } |
| } |
|
|
| |
| async function detectPhoneAndGetBoxes() { |
| try { |
| |
| const targetWidth = 256; |
| const targetHeight = Math.round((targetWidth / offscreen.width) * offscreen.height); |
|
|
| |
| if (smallCanvas.width !== targetWidth || smallCanvas.height !== targetHeight) { |
| smallCanvas.width = targetWidth; |
| smallCanvas.height = targetHeight; |
| } |
|
|
| |
| offscreenCtx.drawImage(video, 0, 0); |
| smallCtx.drawImage(offscreen, 0, 0, targetWidth, targetHeight); |
|
|
| const image = RawImage.fromCanvas(smallCanvas); |
|
|
| |
| const inputs = await processor(image); |
| const output = await model(inputs); |
|
|
| |
| const scores = output.logits.sigmoid().data; |
| const boxes = output.pred_boxes.data; |
|
|
| |
| const confidenceThreshold = lastPhoneBox ? TRACKING_CONFIDENCE : DETECTION_CONFIDENCE; |
|
|
| |
| const newDetections = []; |
| let bestPhone = null; |
| let bestScore = 0; |
|
|
| |
| for (let i = 0; i < 300; i++) { |
| let maxScore = 0, maxClass = 0; |
|
|
| |
| for (let j = 0; j < 80; j++) { |
| const score = scores[i * 80 + j]; |
| if (score > maxScore) { |
| maxScore = score; |
| maxClass = j; |
| } |
| } |
|
|
| |
| if (maxClass === PHONE_CLASS_ID && maxScore >= confidenceThreshold) { |
| |
| const cx = boxes[i * 4]; |
| const cy = boxes[i * 4 + 1]; |
| const w = boxes[i * 4 + 2]; |
| const h = boxes[i * 4 + 3]; |
|
|
| |
| const scaleX = canvas.width / targetWidth; |
| const scaleY = canvas.height / targetHeight; |
|
|
| const x1 = (cx - w / 2) * targetWidth * scaleX; |
| const y1 = (cy - h / 2) * targetHeight * scaleY; |
| const x2 = (cx + w / 2) * targetWidth * scaleX; |
| const y2 = (cy + h / 2) * targetHeight * scaleY; |
|
|
| const detection = { |
| x1, y1, x2, y2, |
| confidence: maxScore, |
| class: 'cell phone' |
| }; |
|
|
| |
| if (maxScore > bestScore) { |
| bestScore = maxScore; |
| bestPhone = detection; |
| } |
| } |
| } |
|
|
| |
| if (bestPhone) { |
| |
| lastPhoneBox = bestPhone; |
| framesWithoutDetection = 0; |
| newDetections.push(bestPhone); |
| } if (lastPhoneBox && framesWithoutDetection < TRACKING_PERSIST_FRAMES) { |
| |
| framesWithoutDetection++; |
| newDetections.push({ |
| ...lastPhoneBox, |
| confidence: lastPhoneBox.confidence * 0.9 |
| }); |
| } else { |
| |
| lastPhoneBox = null; |
| framesWithoutDetection = 0; |
| } |
|
|
| |
| return newDetections; |
|
|
| } catch (error) { |
| console.error('Detection error:', error); |
| return []; |
| } |
| } |
|
|
| |
| function draw(detections) { |
| |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
|
|
| |
| ctx.drawImage(video, 0, 0, canvas.width, canvas.height); |
|
|
| |
| for (const det of detections) { |
| |
| ctx.strokeStyle = '#00ff00'; |
| ctx.lineWidth = 3; |
| ctx.strokeRect(det.x1, det.y1, det.x2 - det.x1, det.y2 - det.y1); |
|
|
| |
| ctx.fillStyle = '#00ff00'; |
| ctx.font = '16px Arial'; |
| const text = `${det.class} ${(det.confidence * 100).toFixed(0)}%`; |
| ctx.fillText(text, det.x1, det.y1 - 10); |
| } |
| } |
|
|
| |
| async function playReachyEmotion(emotionList) { |
| |
| const emotionName = emotionList[Math.floor(Math.random() * emotionList.length)]; |
| const audioUrl = `${EMOTIONS_BASE_URL}/${emotionName}.wav`; |
|
|
| try { |
| const audio = new Audio(audioUrl); |
| await audio.play(); |
| return emotionName; |
| } catch (error) { |
| console.warn(`Failed to play emotion ${emotionName}:`, error); |
| return null; |
| } |
| } |
|
|
| |
| async function handlePhonePickup() { |
| |
| robotSvg.setAttribute('data', 'reachy-mad.svg'); |
| robotSvg.classList.add('shaking'); |
|
|
| |
| const emotionName = await playReachyEmotion(SHAME_EMOTIONS); |
|
|
| |
| if (emotionName) { |
| responseText.textContent = `😡 *${emotionName}*`; |
| } |
|
|
| |
| setTimeout(() => { |
| robotSvg.classList.remove('shaking'); |
| }, 2000); |
| } |
|
|
| |
| async function handlePhonePutdown() { |
| |
| robotSvg.classList.add('nodding'); |
| robotSvg.setAttribute('data', 'reachy-happy.svg'); |
|
|
| |
| const emotionName = await playReachyEmotion(PRAISE_EMOTIONS); |
|
|
| |
| if (emotionName) { |
| responseText.textContent = `✨ *${emotionName}*`; |
| } |
|
|
| |
| setTimeout(() => { |
| robotSvg.classList.remove('nodding'); |
| }, 1500); |
| } |
|
|
| |
|
|
| |
| cameraBtn.addEventListener('click', async () => { |
| if (!isRunning) { |
| |
| await startCamera(); |
| cameraIcon.textContent = '🎥'; |
| cameraText.textContent = 'Close Camera'; |
| startBtn.disabled = false; |
| isMonitoring = true; |
| btnIcon.textContent = '🛑'; |
| btnText.textContent = 'Stop Monitoring'; |
| statusIndicator.className = 'status-dot monitoring'; |
|
|
| } else { |
| |
| isMonitoring = false; |
| stopCamera(); |
| cameraIcon.textContent = '📹'; |
| cameraText.textContent = 'Open Camera'; |
| startBtn.disabled = true; |
| btnIcon.textContent = '▶️'; |
| btnText.textContent = 'Start Monitoring'; |
| statusText.textContent = 'Camera closed'; |
| statusIndicator.className = 'status-dot ready'; |
| robotSvg.setAttribute('data', 'reachy-happy.svg'); |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| |
| |
| phoneVisible = false; |
| consecutivePhone = 0; |
| consecutiveNoPhone = 0; |
| lastPhoneBox = null; |
| framesWithoutDetection = 0; |
| lastReactionTime = 0; |
| } |
| }); |
|
|
| startBtn.addEventListener('click', async () => { |
| isMonitoring = !isMonitoring; |
| if (isMonitoring) { |
| btnIcon.textContent = '🛑'; |
| btnText.textContent = 'Stop Monitoring'; |
| statusIndicator.className = 'status-dot monitoring'; |
|
|
| } else { |
| btnIcon.textContent = '▶️'; |
| btnText.textContent = 'Start Monitoring'; |
| statusText.textContent = 'Paused'; |
| statusIndicator.className = 'status-dot ready'; |
| ctx.clearRect(0, 0, canvas.width, canvas.height); |
| robotSvg.setAttribute('data', 'reachy-happy.svg'); |
|
|
| |
| phoneVisible = false; |
| consecutivePhone = 0; |
| consecutiveNoPhone = 0; |
| lastPhoneBox = null; |
| framesWithoutDetection = 0; |
| lastReactionTime = 0; |
| } |
| }); |
|
|
| |
| init(); |
|
|