Maison >interface Web >js tutoriel >Créer une expérience de suivi oculaire en temps réel avec Supabase et WebGazer.js
TL;DR :
- Construit avec Supabase, React, WebGazer.js, Motion One, anime.js, Stable Audio
- Exploite la présence et la diffusion en temps réel de Supabase (aucune table de base de données utilisée !)
- Dépôt GitHub
- Site Internet
- Vidéo de démonstration
Encore un autre hackathon de la Supabase Launch Week et encore un autre projet expérimental, appelé Gaze into the Abyss. Cela a fini par être à la fois l'un des projets les plus simples et les plus complexes. Heureusement, j'ai beaucoup apprécié Cursor ces derniers temps, j'ai donc eu un coup de main pour m'en sortir ! Je voulais également valider une question dans mon esprit : est-il possible d'utiliser juste les fonctionnalités temps réel de Supabase sans tables de base de données ? La réponse (peut-être quelque peu évidente) est : oui, oui (je vous aime, équipe Realtime ♥️). Alors approfondissons un peu la mise en œuvre.
Un jour, je pensais au hasard à la citation de Nietzsche sur l'abîme et au fait que ce serait bien (et cool) de la visualiser d'une manière ou d'une autre : vous regardez dans un écran sombre et quelque chose vous regarde en retour. Rien de plus!
Au départ, j'avais l'idée d'utiliser Three.js pour réaliser ce projet, mais j'ai réalisé que cela signifierait que je devrais créer ou trouver des ressources gratuites pour les yeux 3D. J'ai décidé que c'était un peu trop, d'autant plus que je n'avais pas trop de temps pour travailler sur le projet lui-même, et j'ai décidé de le faire en 2D avec des SVG à la place.
Je ne voulais pas non plus que ce soit uniquement visuel : ce serait une meilleure expérience avec un peu d'audio aussi. J'ai donc eu l'idée que ce serait génial si les participants pouvaient parler dans un microphone et que les autres pouvaient l'entendre comme des murmures inéligibles ou des vents passant. Cependant, cela s'est avéré très difficile et j'ai décidé de l'abandonner complètement car je n'étais pas en mesure de bien connecter WebAudio et WebRTC. J'ai un composant restant dans la base de code qui écoute le microphone local et déclenche des "sons de vent" pour l'utilisateur actuel si vous souhaitez y jeter un œil. Peut-être quelque chose à ajouter dans le futur ?
Avant de travailler sur des éléments visuels, je voulais tester la configuration temps réel que j'avais en tête. Comme il y a certaines limitations dans la fonctionnalité temps réel, je voulais qu'elle fonctionne de telle sorte que :
Pour cela, j'ai proposé une configuration useEffect où il se joint de manière récursive à un canal en temps réel comme ceci :
Ce joinRoom réside à l'intérieur du hook useEffect et est appelé lorsque le composant room est monté. Une mise en garde que j'ai découverte en travaillant sur cette fonctionnalité était que le paramètre currentPresences ne contient aucune valeur dans l'événement de jointure même s'il est disponible. Je ne sais pas s'il s'agit d'un bug dans l'implémentation ou s'il fonctionne comme prévu. Il faut donc effectuer une récupération manuelle de room.presenceState pour obtenir le nombre de participants dans la salle chaque fois que l'utilisateur se joint.
Nous vérifions le nombre de participants et soit nous nous désabonnons de la salle actuelle et essayons de rejoindre une autre salle, soit nous continuons avec la salle actuelle. Nous faisons cela dans l'événement de jointure car la synchronisation serait trop tardive (elle se déclenche après les événements de jointure ou de sortie).
J'ai testé cette implémentation en ouvrant tout un tas d'onglets dans mon navigateur et tout semblait bien !
Après cela, j'ai voulu déboguer la solution avec des mises à jour de la position de la souris et j'ai rapidement rencontré des problèmes d'envoi de trop de messages dans le canal ! La solution : limiter les appels.
/** * Creates a throttled version of a function that can only be called at most once * in the specified time period. */ function createThrottledFunction<T extends (...args: unknown[]) => unknown>( functionToThrottle: T, waitTimeMs: number ): (...args: Parameters<T>) => void { let isWaitingToExecute = false return function throttledFunction(...args: Parameters<T>) { if (!isWaitingToExecute) { functionToThrottle.apply(this, args) isWaitingToExecute = true setTimeout(() => { isWaitingToExecute = false }, waitTimeMs) } } }
Cursor a inventé ce petit créateur de fonction d'accélérateur et je l'ai utilisé avec les émissions de suivi oculaire comme ceci :
const throttledBroadcast = createThrottledFunction((data: EyeTrackingData) => { if (currentChannel) { currentChannel.send({ type: 'broadcast', event: 'eye_tracking', payload: data }) } }, THROTTLE_MS) throttledBroadcast({ userId: userId.current, isBlinking: isCurrentlyBlinking, gazeX, gazeY })
Cela a beaucoup aidé ! De plus, dans les versions initiales, les messages de suivi oculaire étaient envoyés avec présence, mais la diffusion permet plus de messages par seconde, j'ai donc changé l'implémentation vers cela. C'est particulièrement crucial dans le suivi oculaire puisque la caméra enregistrera tout à tout moment.
J'étais tombé sur WebGazer.js il y a quelque temps lorsque j'ai eu l'idée de ce projet pour la première fois. C'est un projet très intéressant et qui fonctionne étonnamment bien !
Toutes les capacités de suivi oculaire sont réalisées dans une seule fonction dans un hook useEffect :
window.webgazer .setGazeListener(async (data: any) => { if (data == null || !currentChannel || !ctxRef.current) return try { // Get normalized gaze coordinates const gazeX = data.x / windowSize.width const gazeY = data.y / windowSize.height // Get video element const videoElement = document.getElementById('webgazerVideoFeed') as HTMLVideoElement if (!videoElement) { console.error('WebGazer video element not found') return } // Set canvas size to match video imageCanvasRef.current.width = videoElement.videoWidth imageCanvasRef.current.height = videoElement.videoHeight // Draw current frame to canvas ctxRef.current?.drawImage(videoElement, 0, 0) // Get eye patches const tracker = window.webgazer.getTracker() const patches = await tracker.getEyePatches( videoElement, imageCanvasRef.current, videoElement.videoWidth, videoElement.videoHeight ) if (!patches?.right?.patch?.data || !patches?.left?.patch?.data) { console.error('No eye patches detected') return } // Calculate brightness for each eye const calculateBrightness = (imageData: ImageData) => { let total = 0 for (let i = 0; i < imageData.data.length; i += 16) { // Convert RGB to grayscale const r = imageData.data[i] const g = imageData.data[i + 1] const b = imageData.data[i + 2] total += (r + g + b) / 3 } return total / (imageData.width * imageData.height / 4) } const rightEyeBrightness = calculateBrightness(patches.right.patch) const leftEyeBrightness = calculateBrightness(patches.left.patch) const avgBrightness = (rightEyeBrightness + leftEyeBrightness) / 2 // Update rolling average if (brightnessSamples.current.length >= SAMPLES_SIZE) { brightnessSamples.current.shift() // Remove oldest sample } brightnessSamples.current.push(avgBrightness) // Calculate dynamic threshold from rolling average const rollingAverage = brightnessSamples.current.reduce((a, b) => a + b, 0) / brightnessSamples.current.length const dynamicThreshold = rollingAverage * THRESHOLD_MULTIPLIER // Detect blink using dynamic threshold const blinkDetected = avgBrightness > dynamicThreshold // Debounce blink detection to avoid rapid changes if (blinkDetected !== isCurrentlyBlinking) { const now = Date.now() if (now - lastBlinkTime > 100) { // Minimum time between blink state changes isCurrentlyBlinking = blinkDetected lastBlinkTime = now } } // Use throttled broadcast instead of direct send throttledBroadcast({ userId: userId.current, isBlinking: isCurrentlyBlinking, gazeX, gazeY }) } catch (error) { console.error('Error processing gaze data:', error) } })
Obtenir les informations là où l'utilisateur regarde est simple et fonctionne comme obtenir les positions de la souris sur l'écran. Cependant, je voulais également ajouter la détection des clignements en tant que fonctionnalité (intéressante), ce qui nécessitait de franchir quelques obstacles.
Lorsque vous recherchez sur Google des informations sur WebGazer et la détection des clignements, vous pouvez voir quelques rémanences d'une implémentation initiale. Comme s'il y avait même du code commenté dans la source. Malheureusement, ce type de fonctionnalités n'existe pas dans la bibliothèque. Vous devrez le faire manuellement.
Après de nombreux essais et erreurs, Cursor et moi avons pu trouver une solution qui calcule les pixels et les niveaux de luminosité à partir des données du cache-œil pour déterminer quand l'utilisateur cligne des yeux. Il dispose également de quelques ajustements d'éclairage dynamiques car j'ai remarqué que (du moins pour moi) la webcam ne reconnaît pas toujours lorsque vous clignez des yeux en fonction de votre éclairage. Pour moi, plus ma photo/pièce était claire, et mieux dans un éclairage plus sombre (allez comprendre).
Lors du débogage des capacités de suivi oculaire (WebGazer a un très bel appel .setPredictionPoints qui affiche un point rouge sur l'écran pour visualiser où vous regardez), j'ai remarqué que le suivi n'est pas très précis à moins de calibrer it. C'est ce que le projet vous demande de faire avant de rejoindre une pièce.
/** * Creates a throttled version of a function that can only be called at most once * in the specified time period. */ function createThrottledFunction<T extends (...args: unknown[]) => unknown>( functionToThrottle: T, waitTimeMs: number ): (...args: Parameters<T>) => void { let isWaitingToExecute = false return function throttledFunction(...args: Parameters<T>) { if (!isWaitingToExecute) { functionToThrottle.apply(this, args) isWaitingToExecute = true setTimeout(() => { isWaitingToExecute = false }, waitTimeMs) } } }
C'était une expérience très cool de voir cela en action ! J'ai appliqué la même approche aux lignes environnantes et j'ai demandé à Cursor de les « réduire » vers le centre : ce qu'il a fait à peu près d'un seul coup !
Les yeux seraient ensuite rendus dans une simple grille CSS avec des cellules alignées de manière à ce qu'une pièce pleine ressemble à un grand œil.
const throttledBroadcast = createThrottledFunction((data: EyeTrackingData) => { if (currentChannel) { currentChannel.send({ type: 'broadcast', event: 'eye_tracking', payload: data }) } }, THROTTLE_MS) throttledBroadcast({ userId: userId.current, isBlinking: isCurrentlyBlinking, gazeX, gazeY })
Ensuite, lancez un bel écran d'introduction et une musique de fond et le projet est prêt à démarrer !
L'audio améliore toujours l'expérience lorsque vous travaillez sur des choses comme celle-ci, j'ai donc utilisé Stable Audio pour générer une musique de fond lorsque l'utilisateur "entre dans l'abîme". L'invite que j'ai utilisée pour la musique était la suivante :
Ambiant, effrayant, musique de fond, chuchotements, vents, tempo lent, étrange, abîme
J'ai aussi pensé qu'un simple écran noir était un peu ennuyeux, alors j'ai ajouté des filtres SVG animés en arrière-plan. De plus, j'ai ajouté un cercle sombre et flou au centre de l'écran pour obtenir un bel effet de fondu. J'aurais probablement pu le faire avec des filtres SVG, mais je ne voulais pas y consacrer trop de temps. Puis pour avoir encore plus de mouvement, j'ai fait tourner le fond sur son axe. Parfois, faire des animations avec les filtres SVG est un peu bizarre, j'ai donc décidé de le faire de cette façon.
<div> <h2> Conclusion </h2> <p>Alors voilà : regardez assez simplement comment mettre en œuvre un suivi oculaire stylisé avec les capacités en temps réel de Supabase. Personnellement, j'ai trouvé cette expérience très intéressante et je n'ai pas eu trop de problèmes en y travaillant. Et étonnamment, je n'ai pas eu à faire une nuit blanche la dernière nuit avant de soumettre le projet !</p> <p>N'hésitez pas à consulter le projet ou la vidéo de démonstration pour découvrir le résultat. Il peut y avoir des problèmes si plusieurs personnes l'utilisent en même temps (très difficile à tester car cela nécessite plusieurs appareils et webcams pour le faire correctement), mais je suppose que c'est à la mode des projets de hackathon ? Et si vous le testez, rappelez-vous que si vous voyez un œil, c'est quelqu'un d'autre qui vous surveille quelque part sur Internet !</p>
Ce qui précède est le contenu détaillé de. pour plus d'informations, suivez d'autres articles connexes sur le site Web de PHP en chinois!