Spaces:
Running
Running
| <html lang="fr"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Suivi d'Objet en Temps Réel - Vision IA</title> | |
| <!-- Importation de polices et d'icônes pour le style --> | |
| <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet"> | |
| <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
| <style> | |
| :root { | |
| --primary: #00f2ff; | |
| --primary-dim: rgba(0, 242, 255, 0.1); | |
| --secondary: #ff0055; | |
| --bg-dark: #0a0e17; | |
| --bg-panel: #111827; | |
| --text-main: #e2e8f0; | |
| --text-muted: #94a3b8; | |
| --border: #1e293b; | |
| --success: #10b981; | |
| } | |
| * { | |
| box-sizing: border-box; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| body { | |
| font-family: 'Inter', sans-serif; | |
| background-color: var(--bg-dark); | |
| color: var(--text-main); | |
| height: 100vh; | |
| display: flex; | |
| flex-direction: column; | |
| overflow: hidden; | |
| } | |
| /* En-tête */ | |
| header { | |
| background: var(--bg-panel); | |
| border-bottom: 1px solid var(--border); | |
| padding: 1rem 2rem; | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| z-index: 10; | |
| } | |
| .brand { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| font-weight: 600; | |
| font-size: 1.2rem; | |
| color: var(--primary); | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| } | |
| .brand i { | |
| font-size: 1.4rem; | |
| } | |
| .anycoder-link { | |
| font-size: 0.85rem; | |
| color: var(--text-muted); | |
| text-decoration: none; | |
| border: 1px solid var(--border); | |
| padding: 0.4rem 0.8rem; | |
| border-radius: 4px; | |
| transition: all 0.3s ease; | |
| } | |
| .anycoder-link:hover { | |
| color: var(--primary); | |
| border-color: var(--primary); | |
| box-shadow: 0 0 10px var(--primary-dim); | |
| } | |
| /* Layout Principal */ | |
| main { | |
| flex: 1; | |
| display: grid; | |
| grid-template-columns: 1fr 320px; | |
| gap: 0; | |
| height: calc(100vh - 70px); | |
| } | |
| /* Zone de la Caméra / Canvas */ | |
| .viewport { | |
| position: relative; | |
| background: #000; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| overflow: hidden; | |
| } | |
| video { | |
| display: none; /* Vidéo cachée, on affiche le canvas traité */ | |
| } | |
| canvas { | |
| max-width: 100%; | |
| max-height: 100%; | |
| cursor: crosshair; | |
| } | |
| .overlay-msg { | |
| position: absolute; | |
| color: var(--text-muted); | |
| text-align: center; | |
| pointer-events: none; | |
| } | |
| .overlay-msg i { | |
| font-size: 3rem; | |
| margin-bottom: 1rem; | |
| opacity: 0.5; | |
| } | |
| /* Panneau de Contrôle */ | |
| .controls { | |
| background: var(--bg-panel); | |
| border-left: 1px solid var(--border); | |
| padding: 1.5rem; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 1.5rem; | |
| overflow-y: auto; | |
| } | |
| .panel-section { | |
| background: rgba(255, 255, 255, 0.03); | |
| border: 1px solid var(--border); | |
| border-radius: 8px; | |
| padding: 1rem; | |
| } | |
| .panel-title { | |
| font-size: 0.85rem; | |
| text-transform: uppercase; | |
| color: var(--text-muted); | |
| margin-bottom: 1rem; | |
| font-weight: 600; | |
| letter-spacing: 0.5px; | |
| display: flex; | |
| justify-content: space-between; | |
| } | |
| /* Boutons */ | |
| .btn { | |
| width: 100%; | |
| padding: 0.8rem; | |
| border: none; | |
| border-radius: 6px; | |
| font-weight: 600; | |
| cursor: pointer; | |
| transition: all 0.2s; | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| gap: 8px; | |
| font-family: inherit; | |
| } | |
| .btn-primary { | |
| background: var(--primary); | |
| color: #000; | |
| } | |
| .btn-primary:hover { | |
| background: #6ff9ff; | |
| box-shadow: 0 0 15px var(--primary-dim); | |
| } | |
| .btn-danger { | |
| background: rgba(255, 0, 85, 0.1); | |
| color: var(--secondary); | |
| border: 1px solid var(--secondary); | |
| } | |
| .btn-danger:hover { | |
| background: var(--secondary); | |
| color: #fff; | |
| } | |
| /* Sliders */ | |
| .control-group { | |
| margin-bottom: 1rem; | |
| } | |
| .control-group label { | |
| display: flex; | |
| justify-content: space-between; | |
| font-size: 0.9rem; | |
| margin-bottom: 0.5rem; | |
| } | |
| input[type="range"] { | |
| width: 100%; | |
| height: 6px; | |
| background: var(--border); | |
| border-radius: 3px; | |
| outline: none; | |
| -webkit-appearance: none; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none; | |
| width: 16px; | |
| height: 16px; | |
| background: var(--primary); | |
| border-radius: 50%; | |
| cursor: pointer; | |
| transition: transform 0.1s; | |
| } | |
| input[type="range"]::-webkit-slider-thumb:hover { | |
| transform: scale(1.2); | |
| } | |
| /* Indicateurs de données */ | |
| .data-grid { | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 0.5rem; | |
| } | |
| .data-item { | |
| background: rgba(0, 0, 0, 0.2); | |
| padding: 0.5rem; | |
| border-radius: 4px; | |
| } | |
| .data-label { | |
| font-size: 0.7rem; | |
| color: var(--text-muted); | |
| } | |
| .data-value { | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 1rem; | |
| color: var(--primary); | |
| } | |
| /* Indicateur de couleur cible */ | |
| .target-color-preview { | |
| width: 100%; | |
| height: 40px; | |
| border-radius: 4px; | |
| border: 1px solid var(--border); | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| color: #fff; | |
| text-shadow: 0 1px 3px rgba(0,0,0,0.8); | |
| font-weight: bold; | |
| font-size: 0.9rem; | |
| margin-bottom: 1rem; | |
| transition: background 0.2s; | |
| } | |
| /* Toast de notification */ | |
| .toast-container { | |
| position: fixed; | |
| bottom: 20px; | |
| left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 100; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 10px; | |
| } | |
| .toast { | |
| background: rgba(17, 24, 39, 0.9); | |
| border: 1px solid var(--primary); | |
| color: var(--text-main); | |
| padding: 10px 20px; | |
| border-radius: 4px; | |
| font-size: 0.9rem; | |
| backdrop-filter: blur(5px); | |
| animation: slideUp 0.3s ease-out forwards; | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| } | |
| @keyframes slideUp { | |
| from { opacity: 0; transform: translateY(20px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* Responsive Design */ | |
| @media (max-width: 900px) { | |
| main { | |
| grid-template-columns: 1fr; | |
| grid-template-rows: 1fr auto; | |
| overflow-y: auto; | |
| } | |
| .controls { | |
| border-left: none; | |
| border-top: 1px solid var(--border); | |
| height: auto; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="brand"> | |
| <i class="fa-solid fa-crosshairs"></i> | |
| <span>Vision Tracker</span> | |
| </div> | |
| <a href="https://huggingface.co/spaces/akhaliq/anycoder" target="_blank" class="anycoder-link"> | |
| Built with anycoder <i class="fa-solid fa-external-link-alt" style="font-size: 0.7em; margin-left:5px;"></i> | |
| </a> | |
| </header> | |
| <main> | |
| <!-- Zone d'affichage --> | |
| <section class="viewport" id="viewport"> | |
| <div class="overlay-msg" id="placeholder"> | |
| <i class="fa-solid fa-video-slash"></i> | |
| <p>Caméra arrêtée</p> | |
| </div> | |
| <video id="video" playsinline></video> | |
| <canvas id="canvas"></canvas> | |
| </section> | |
| <!-- Panneau de contrôle --> | |
| <aside class="controls"> | |
| <div class="panel-section"> | |
| <div class="panel-title">Contrôle Caméra</div> | |
| <button id="btn-toggle-cam" class="btn btn-primary"> | |
| <i class="fa-solid fa-power-off"></i> Démarrer Caméra | |
| </button> | |
| </div> | |
| <div class="panel-section"> | |
| <div class="panel-title"> | |
| Configuration Tracking | |
| <i class="fa-solid fa-sliders"></i> | |
| </div> | |
| <div class="target-color-preview" id="color-preview" style="background-color: #333;"> | |
| Aucune couleur sélectionnée | |
| </div> | |
| <p style="font-size: 0.8rem; color: var(--text-muted); margin-bottom: 1rem;"> | |
| <i class="fa-solid fa-circle-info"></i> Cliquez sur l'image pour choisir la cible. | |
| </p> | |
| <div class="control-group"> | |
| <label> | |
| Tolérance (Sensibilité) | |
| <span id="tolerance-val">40</span> | |
| </label> | |
| <input type="range" id="tolerance" min="5" max="150" value="40"> | |
| </div> | |
| <div class="control-group"> | |
| <label> | |
| Taille Min. Objet (px) | |
| <span id="min-size-val">500</span> | |
| </label> | |
| <input type="range" id="min-size" min="100" max="5000" value="500"> | |
| </div> | |
| </div> | |
| <div class="panel-section"> | |
| <div class="panel-title"> | |
| Télémétrie | |
| <i class="fa-solid fa-satellite-dish"></i> | |
| </div> | |
| <div class="data-grid"> | |
| <div class="data-item"> | |
| <div class="data-label">Position X</div> | |
| <div class="data-value" id="pos-x">0</div> | |
| </div> | |
| <div class="data-item"> | |
| <div class="data-label">Position Y</div> | |
| <div class="data-value" id="pos-y">0</div> | |
| </div> | |
| <div class="data-item"> | |
| <div class="data-label">Surface (px²)</div> | |
| <div class="data-value" id="surface">0</div> | |
| </div> | |
| <div class="data-item"> | |
| <div class="data-label">FPS</div> | |
| <div class="data-value" id="fps">0</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="panel-section"> | |
| <div class="panel-title">Debug</div> | |
| <label style="display: flex; align-items: center; gap: 10px; cursor: pointer; font-size: 0.9rem;"> | |
| <input type="checkbox" id="show-mask"> Afficher le masque de couleur | |
| </label> | |
| </div> | |
| </aside> | |
| </main> | |
| <div class="toast-container" id="toast-container"></div> | |
| <script> | |
| /** | |
| * Logique de l'application de suivi d'objet | |
| * Utilise l'API Canvas pour analyser les pixels de la vidéo en temps réel. | |
| */ | |
| // Éléments du DOM | |
| const video = document.getElementById('video'); | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d', { willReadFrequently: true }); | |
| const viewport = document.getElementById('viewport'); | |
| const placeholder = document.getElementById('placeholder'); | |
| // Contrôles | |
| const btnToggleCam = document.getElementById('btn-toggle-cam'); | |
| const toleranceInput = document.getElementById('tolerance'); | |
| const toleranceVal = document.getElementById('tolerance-val'); | |
| const minSizeInput = document.getElementById('min-size'); | |
| const minSizeVal = document.getElementById('min-size-val'); | |
| const colorPreview = document.getElementById('color-preview'); | |
| const showMaskCheckbox = document.getElementById('show-mask'); | |
| // Affichage données | |
| const posXDisplay = document.getElementById('pos-x'); | |
| const posYDisplay = document.getElementById('pos-y'); | |
| const surfaceDisplay = document.getElementById('surface'); | |
| const fpsDisplay = document.getElementById('fps'); | |
| // État de l'application | |
| let isStreaming = false; | |
| let animationId = null; | |
| let targetColor = null; // {r, g, b} | |
| let lastFrameTime = 0; | |
| // Configuration | |
| let config = { | |
| tolerance: 40, | |
| minObjectSize: 500, | |
| scanStep: 4 // Performance : vérifier 1 pixel sur 4 | |
| }; | |
| // Gestion des notifications (Toast) | |
| function showToast(message, type = 'info') { | |
| const container = document.getElementById('toast-container'); | |
| const toast = document.createElement('div'); | |
| toast.className = 'toast'; | |
| let icon = '<i class="fa-solid fa-info-circle"></i>'; | |
| if(type === 'success') icon = '<i class="fa-solid fa-check-circle"></i>'; | |
| if(type === 'error') icon = '<i class="fa-solid fa-exclamation-triangle"></i>'; | |
| toast.innerHTML = `${icon} <span>${message}</span>`; | |
| container.appendChild(toast); | |
| setTimeout(() => { | |
| toast.style.opacity = '0'; | |
| toast.style.transform = 'translateY(20px)'; | |
| setTimeout(() => toast.remove(), 300); | |
| }, 3000); | |
| } | |
| // Démarrage / Arrêt de la caméra | |
| btnToggleCam.addEventListener('click', async () => { | |
| if (isStreaming) { | |
| stopCamera(); | |
| } else { | |
| await startCamera(); | |
| } | |
| }); | |
| async function startCamera() { | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ | |
| video: { | |
| width: { ideal: 640 }, | |
| height: { ideal: 480 }, | |
| facingMode: 'user' | |
| }, | |
| audio: false | |
| }); | |
| video.srcObject = stream; | |
| video.onloadedmetadata = () => { | |
| video.play(); | |
| canvas.width = video.videoWidth; | |
| canvas.height = video.videoHeight; | |
| isStreaming = true; | |
| btnToggleCam.innerHTML = '<i class="fa-solid fa-stop"></i> Arrêter Caméra'; | |
| btnToggleCam.classList.replace('btn-primary', 'btn-danger'); | |
| placeholder.style.display = 'none'; | |
| showToast("Caméra active. Cliquez sur un objet pour le suivre.", "success"); | |
| // Boucle de traitement | |
| requestAnimationFrame(processFrame); | |
| }; | |
| } catch (err) { | |
| console.error("Erreur caméra:", err); | |
| showToast("Impossible d'accéder à la caméra.", "error"); | |
| } | |
| } | |
| function stopCamera() { | |
| if (video.srcObject) { | |
| video.srcObject.getTracks().forEach(track => track.stop()); | |
| video.srcObject = null; | |
| } | |
| isStreaming = false; | |
| cancelAnimationFrame(animationId); | |
| // Nettoyer canvas | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| btnToggleCam.innerHTML = '<i class="fa-solid fa-power-off"></i> Démarrer Caméra'; | |
| btnToggleCam.classList.replace('btn-danger', 'btn-primary'); | |
| placeholder.style.display = 'block'; | |
| // Reset données | |
| posXDisplay.innerText = '0'; | |
| posYDisplay.innerText = '0'; | |
| surfaceDisplay.innerText = '0'; | |
| fpsDisplay.innerText = '0'; | |
| } | |
| // Sélection de la couleur par clic | |
| canvas.addEventListener('click', (e) => { | |
| if (!isStreaming) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| // Calculer l'échelle car le canvas CSS peut être redimensionné | |
| const scaleX = canvas.width / rect.width; | |
| const scaleY = canvas.height / rect.height; | |
| const x = (e.clientX - rect.left) * scaleX; | |
| const y = (e.clientY - rect.top) * scaleY; | |
| // Lire le pixel actuel | |
| const pixel = ctx.getImageData(x, y, 1, 1).data; | |
| targetColor = { r: pixel[0], g: pixel[1], b: pixel[2] }; | |
| // Mettre à jour l'UI | |
| const rgbString = `rgb(${targetColor.r}, ${targetColor.g}, ${targetColor.b})`; | |
| colorPreview.style.backgroundColor = rgbString; | |
| colorPreview.innerText = `Cible: ${rgbString}`; | |
| showToast("Couleur cible verrouillée !", "success"); | |
| }); | |
| // Mise à jour de la configuration | |
| toleranceInput.addEventListener('input', (e) => { | |
| config.tolerance = parseInt(e.target.value); | |
| toleranceVal.innerText = config.tolerance; | |
| }); | |
| minSizeInput.addEventListener('input', (e) => { | |
| config.minObjectSize = parseInt(e.target.value); | |
| minSizeVal.innerText = config.minObjectSize; | |
| }); | |
| // Fonction principale de traitement d'image | |
| function processFrame(timestamp) { | |
| if (!isStreaming) return; | |
| // Calcul FPS | |
| const deltaTime = timestamp - lastFrameTime; | |
| lastFrameTime = timestamp; | |
| if (timestamp % 10 < 1) { // Mise à jour occasionnelle | |
| fpsDisplay.innerText = Math.round(1000 / deltaTime) || 0; | |
| } | |
| // 1. Dessiner l'image vidéo sur le canvas | |
| ctx.drawImage(video, 0, 0, canvas.width, canvas.height); | |
| // Si une couleur cible est définie, on lance le tracking | |
| if (targetColor) { | |
| trackObject(); | |
| } | |
| animationId = requestAnimationFrame(processFrame); | |
| } | |
| function trackObject() { | |
| const width = canvas.width; | |
| const height = canvas.height; | |
| // Obtenir les données brutes de l'image | |
| const frameData = ctx.getImageData(0, 0, width, height); | |
| const data = frameData.data; | |
| const length = data.length; | |
| let minX = width, minY = height, maxX = 0, maxY = 0; | |
| let pixelCount = 0; | |
| const rT = targetColor.r; | |
| const gT = targetColor.g; | |
| const bT = targetColor.b; | |
| const tol = config.tolerance; | |
| const tolSq = tol * tol * 3; // Optimisation : comparer au carré | |
| // Scan des pixels (avec step pour perf) | |
| for (let i = 0; i < length; i += 4 * config.scanStep) { | |
| const r = data[i]; | |
| const g = data[i + 1]; | |
| const b = data[i + 2]; | |
| // Calcul de distance de couleur (Euclidienne simplifiée) | |
| // On utilise une approximation rapide pour la performance | |
| const dist = (r - rT) ** 2 + (g - gT) ** 2 + (b - bT) ** 2; | |
| if (dist < tolSq) { | |
| // Pixel correspondant trouvé | |
| const pixelIndex = i / 4; | |
| const x = pixelIndex % width; | |
| const y = Math.floor(pixelIndex / width); | |
| if (x < minX) minX = x; | |
| if (x > maxX) maxX = x; | |
| if (y < minY) minY = y; | |
| if (y > maxY) maxY = y; | |
| pixelCount++; | |
| // Optionnel : dessiner le masque (pour debug) | |
| if (showMaskCheckbox.checked) { | |
| data[i] = 0; // R | |
| data[i + 1] = 255; // G | |
| data[i + 2] = 0; // B | |
| } | |
| } | |
| } | |
| // Si on a activé le masque, on remet les pixels modifiés sur le canvas | |
| if (showMaskCheckbox.checked) { | |
| ctx.putImageData(frameData, 0, 0); | |
| } | |
| // Calcul des dimensions de l'objet détecté | |
| const boxWidth = maxX - minX; | |
| const boxHeight = maxY - minY; | |
| const area = boxWidth * boxHeight; | |
| // Filtrer le bruit (surface minimale) | |
| if (area > config.minObjectSize) { | |
| // Dessiner l'interface utilisateur (HUD) | |
| drawHUD(minX, minY, boxWidth, boxHeight); | |
| // Mettre à jour les données textuelles | |
| posXDisplay.innerText = Math.round(minX + boxWidth / 2); | |
| posYDisplay.innerText = Math.round(minY + boxHeight / 2); | |
| surfaceDisplay.innerText = area; | |
| } else { | |
| // Si perdu | |
| surfaceDisplay.innerText = "Recherche..."; | |
| posXDisplay.innerText = "-"; | |
| posYDisplay.innerText = "-"; | |
| } | |
| } | |
| function drawHUD(x, y, w, h) { | |
| const centerX = x + w / 2; | |
| const centerY = y + h / 2; | |
| ctx.strokeStyle = '#00f2ff'; | |
| ctx.lineWidth = 2; | |
| ctx.shadowBlur = 10; | |
| ctx.shadowColor = '#00f2ff'; | |
| // Dessiner les coins (Style futuriste) | |
| const lineLen = Math.min(w, h) * 0.2; | |
| ctx.beginPath(); | |
| // Coin haut gauche | |
| ctx.moveTo(x, y + lineLen); | |
| ctx.lineTo(x, y); | |
| ctx.lineTo(x + lineLen, y); | |
| // Coin haut droite | |
| ctx.moveTo(x + w - lineLen, y); | |
| ctx.lineTo(x + w, y); | |
| ctx.lineTo(x + w, y + lineLen); | |
| // Coin bas droite | |
| ctx.moveTo(x + w, y + h - lineLen); | |
| ctx.lineTo(x + w, y + h); | |
| ctx.lineTo(x + w - lineLen, y + h); | |
| // Coin bas gauche | |
| ctx.moveTo(x + lineLen, y + h); | |
| ctx.lineTo(x, y + h); | |
| ctx.lineTo(x, y + h - lineLen); | |
| ctx.stroke(); | |
| // Lignes de guidage vers le centre | |
| ctx.strokeStyle = 'rgba(0, 242, 255, 0.3)'; | |
| ctx.lineWidth = 1; | |
| ctx.beginPath(); | |
| ctx.moveTo(centerX, 0); | |
| ctx.lineTo(centerX, canvas.height); | |
| ctx.moveTo(0, centerY); | |
| ctx.lineTo(canvas.width, centerY); | |
| ctx.stroke(); | |
| // Cercle central | |
| ctx.beginPath(); | |
| ctx.arc(centerX, centerY, 5, 0, Math.PI * 2); | |
| ctx.fillStyle = '#ff0055'; | |
| ctx.fill(); | |
| // Texte coordonnées | |
| ctx.fillStyle = '#fff'; | |
| ctx.font = '12px JetBrains Mono'; | |
| ctx.fillText(`X:${Math.round(centerX)} Y:${Math.round(centerY)}`, x, y - 10); | |
| // Reset shadow | |
| ctx.shadowBlur = 0; | |
| } | |
| // Gestion du redimensionnement de la fenêtre | |
| window.addEventListener('resize', () => { | |
| if(isStreaming) { | |
| // Le canvas garde sa résolution interne, CSS gère l'affichage | |
| // Mais on peut vouloir ajuster si nécessaire | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |