首页 >web前端 >js教程 >使用 Supabase 和 WebGazer.js 构建实时眼动追踪体验

使用 Supabase 和 WebGazer.js 构建实时眼动追踪体验

Mary-Kate Olsen
Mary-Kate Olsen原创
2024-12-28 11:06:34551浏览

TL;博士:

  • 使用 Supabase、React、WebGazer.js、Motion One、anime.js、稳定音频构建
  • 利用 Supabase 实时呈现和广播(根本不使用数据库表!)
  • GitHub 存储库
  • 网站
  • 演示视频

又一场 Supabase 启动周黑客马拉松和另一个实验项目,名为 凝视深渊。 这最终成为了最简单又最复杂的项目之一。幸运的是,我最近很喜欢 Cursor,所以我得到了一些帮助来完成它!我还想验证我心中的一个问题:是否可以使用 Supabase 的实时功能而无需任何数据库表? (也许有些明显)答案是:是的,是的(爱你,实时团队♥️)。因此,让我们更深入地了解实现。

这个想法

有一天,我随机想到了尼采关于深渊的名言,如果能够以某种方式实际想象它会很好(而且很酷):你凝视着黑暗的屏幕,有东西在凝视着你。没有更多了!

构建项目

最初我的想法是使用 Three.js 来制作这个项目,但我意识到这意味着我需要为 3D 眼睛创建或找到一些免费资源。我认为这有点太多了,特别是因为我没有太多时间来处理项目本身,因此决定使用 SVG 进行 2D 制作。

我也不希望它只是视觉效果:如果有一些音频也会有更好的体验。所以我有一个想法,如果参与者可以对着麦克风说话,而其他人可以听到不合格的低语或风过时的声音,那就太棒了。然而,这非常具有挑战性,因此我决定完全放弃它,因为我无法将 WebAudio 和 WebRTC 很好地连接在一起。我的代码库中确实有一个剩余组件,如果您想看一下,它会监听本地麦克风并为当前用户触发“风声”。也许将来会添加一些东西?

实时房间

在处理任何视觉内容之前,我想测试一下我想要的实时设置。由于实时功能存在一些限制,我希望它能够工作,以便:

  • 最多有。一个频道一次有 10 名参与者
    • 意味着如果一个新频道已满,您需要加入一个新频道
  • 你应该只看到其他参与者的眼睛

为此,我想出了一个 useEffect 设置,它递归地加入到实时通道,如下所示:





这个 joinRoom 位于 useEffect 钩子内,并在安装房间组件时被调用。我在开发此功能时发现的一个警告是,currentPresences 参数在连接事件中不包含任何值,即使它可用。我不确定这是否是实施中的错误或按预期工作。因此,每当用户加入时,需要手动获取 room.presenceState 来获取房间中的参与者数量。

我们检查参与者数量,然后取消订阅当前房间并尝试加入另一个房间,或者然后继续当前房间。我们在加入事件中执行此操作,因为同步太晚了(它在加入或离开事件后触发)。

我通过在浏览器中打开大量选项卡来测试此实现,一切看起来都很棒!

之后,我想通过鼠标位置更新来调试解决方案,但很快遇到了一些在频道中发送过多消息的问题!解决方案:限制调用。

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

光标想出了这个小油门函数创建器,我将它与眼动追踪广播一起使用,如下所示:

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

这很有帮助!另外,在最初的版本中,我在存在状态下发送了眼动追踪消息,但是广播每秒允许更多消息,所以我将实现切换到了这一点。这在眼动追踪中尤其重要,因为相机会一直记录一切。

眼球追踪

当我第一次有了这个项目的想法时,我就遇到了 WebGazer.js。这是一个非常有趣的项目,而且效果出奇的好!

整个眼球追踪功能是在 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)
        }
      })

获取用户正在查看的信息很简单,就像获取屏幕上的鼠标位置一样。然而,我还想添加眨眼检测作为(一项很酷的)功能,这需要跳过一些环节。

当您在 google 上搜索有关 WebGazer 和眨眼检测的信息时,您可以看到初始实现的一些剩余内容。就像源代码中甚至有注释掉的代码一样。不幸的是,库中不存在此类功能。您需要手动完成。

经过大量的试验和错误,Cursor 和我想出了一个解决方案,可以根据眼罩数据计算像素和亮度级别,以确定用户何时眨眼。它还具有一些动态照明调整功能,因为我注意到(至少对我来说)网络摄像头并不总是能根据您的照明来识别您何时眨眼。对我来说,我的照片/房间越亮,效果越差,而在较暗的灯光下效果更好(见图)。

在调试眼动追踪功能时(WebGazer 有一个非常好的 .setPredictionPoints 调用,它在屏幕上显示一个红点以可视化您正在看的位置),我注意到跟踪不是很准确 除非您进行校准 这是项目要求您在加入任何房间之前要做的事情。







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

看到它的实际应用是一次非常酷的体验!我对周围的线条应用了相同的方法,并指示光标将它们向中心“折叠”:它几乎一气呵成!

然后,眼睛将在一个简单的 CSS 网格内渲染,单元格对齐,这样整个房间看起来就像一只大眼睛。

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

最后的润色

然后播放一些不错的介绍屏幕和背景音乐,项目就可以开始了!

当您处理此类事情时,音频总是可以改善体验,因此我使用稳定音频在用户“进入深渊”时生成背景音乐。我用于音乐的提示如下:

环境、令人毛骨悚然、背景音乐、低语声、风、慢节奏、怪异、深渊

我还认为纯黑屏幕有点无聊,所以我在背景上添加了一些动画 SVG 滤镜。此外,我在屏幕中央添加了一个黑暗的、模糊的圆圈,以产生一些漂亮的淡入淡出效果。我可能可以使用 SVG 滤镜来完成此操作,但我不想在这方面花费太多时间。然后为了有更多的运动,我让背景绕其轴旋转。有时使用 SVG 滤镜制作动画有点奇怪,所以我决定采用这种方式。

 <div>



<h2>
  
  
  结论
</h2>

<p>现在您已经了解了:相当直接地了解如何使用 Supabase 的实时功能实现程式化的眼球追踪。就我个人而言,我发现这是一个非常有趣的实验,并且在进行过程中没有遇到太多问题。令人惊讶的是,在提交项目之前我不需要在最后一晚熬夜!</p>

<p>请随意查看该项目或演示视频的结果。如果一群人同时使用它,可能会出现一些问题(很难测试,因为它需要多个设备和网络摄像头才能正确完成),但我想这是黑客马拉松项目的时尚?如果您确实进行了测试,请记住,如果您看到一只眼睛,那就是其他人通过互联网在某个地方看着您!</p>


          </div>

            
        

以上是使用 Supabase 和 WebGazer.js 构建实时眼动追踪体验的详细内容。更多信息请关注PHP中文网其他相关文章!

声明:
本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系admin@php.cn