Maison >interface Web >js tutoriel >Créer une expérience de suivi oculaire en temps réel avec Supabase et WebGazer.js

Créer une expérience de suivi oculaire en temps réel avec Supabase et WebGazer.js

Mary-Kate Olsen
Mary-Kate Olsenoriginal
2024-12-28 11:06:34567parcourir

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.

L'idée

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!

Construire le projet

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 ?

Salles en temps réel

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 :

  • Il y a max. 10 participants sur un canal à la fois
    • ce qui signifie que vous devrez rejoindre une nouvelle chaîne si celle-ci est pleine
  • Vous ne devriez voir que les yeux des autres participants

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.

Suivi oculaire

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
})

Touches finales

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!

Déclaration:
Le contenu de cet article est volontairement contribué par les internautes et les droits d'auteur appartiennent à l'auteur original. Ce site n'assume aucune responsabilité légale correspondante. Si vous trouvez un contenu suspecté de plagiat ou de contrefaçon, veuillez contacter admin@php.cn