> 웹 프론트엔드 > JS 튜토리얼 > Supabase 및 WebGazer.js를 사용하여 실시간 시선 추적 경험 구축

Supabase 및 WebGazer.js를 사용하여 실시간 시선 추적 경험 구축

Mary-Kate Olsen
풀어 주다: 2024-12-28 11:06:34
원래의
548명이 탐색했습니다.

요약:

  • Supabase, React, WebGazer.js, Motion One, anime.js, Stable Audio로 구축
  • Supabase 실시간 존재 및 브로드캐스트 활용(데이터베이스 테이블이 전혀 사용되지 않음!)
  • GitHub 저장소
  • 웹사이트
  • 데모영상

또 다른 Supabase Launch Week Hackathon과 Gaze into the Abyss라는 또 다른 실험 프로젝트가 있었는데, 이는 결국 가장 단순하면서도 복잡한 프로젝트 중 하나가 되었습니다. 운 좋게도 저는 최근에 Cursor를 꽤 즐기고 있어서 이 과정을 완료하는 데 도움의 손길을 많이 받았습니다! 또한 저는 마음 속으로 한 가지 질문을 확인하고 싶었습니다. 데이터베이스 테이블 없이 Supabase의 실시간 기능을 단지 사용할 수 있습니까? (어쩌면 다소 뻔한) 대답은 다음과 같습니다. 예, 그렇습니다(사랑합니다, Realtime 팀 ♥️). 그럼 구현에 대해 좀 더 자세히 살펴보겠습니다.

아이디어

어느 날 우연히 심연에 대한 니체의 명언에 대해 생각하고 있었는데 어떻게든 그것을 실제로 시각화하면 좋을 것 같다는 생각이 들었습니다. 당신은 어두운 화면을 응시하고 있고 무언가가 당신을 응시하고 있습니다. 그 이상은 없습니다!

프로젝트 빌드

처음에는 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)
        }
      })
로그인 후 복사

사용자가 보고 있는 위치의 정보를 얻는 것은 간단하며 화면에서 마우스 위치를 얻는 것과 같습니다. 하지만 눈 깜박임 감지를 (멋진) 기능으로 추가하고 싶었는데, 이 기능을 사용하려면 몇 가지 어려움을 겪어야 했습니다.

WebGazer 및 깜박임 감지에 대한 정보를 Google에 검색하면 초기 구현의 일부 잔재를 볼 수 있습니다. 소스에도 주석 처리된 코드가 있는 것처럼 말이죠. 불행하게도 이러한 종류의 기능은 라이브러리에 존재하지 않습니다. 수동으로 수행해야 합니다.

많은 시행착오 끝에 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
})
로그인 후 복사
로그인 후 복사

최종 손질

그런 다음 멋진 소개 화면과 배경 음악을 틀면 프로젝트가 시작됩니다!

오디오는 이런 작업을 할 때 항상 경험을 향상시키기 때문에 사용자가 "심연에 들어갈" 때 배경 음악을 생성하기 위해 Stable Audio를 사용했습니다. 제가 음악에 사용한 프롬프트는 다음과 같습니다.

분위기, 오싹함, 배경 음악, 속삭이는 소리, 바람, 느린 템포, 으스스함, 심연

또한 단순한 검은색 화면이 조금 지루하다고 생각하여 배경에 애니메이션 SVG 필터를 추가했습니다. 또한 멋진 페이딩 효과를 주기 위해 화면 중앙에 어둡고 흐린 원을 추가했습니다. 아마도 SVG 필터를 사용하여 이 작업을 수행할 수 있었지만 이에 너무 많은 시간을 소비하고 싶지 않았습니다. 그런 다음 좀 더 움직임을 주기 ​​위해 배경을 축을 중심으로 회전시켰습니다. 때로는 SVG 필터를 사용하여 애니메이션을 수행하는 것이 다소 불안정하므로 대신 이 방법으로 수행하기로 결정했습니다.

 <div>



<h2>
  
  
  결론
</h2>

<p>여기까지 왔습니다. Supabase의 실시간 기능을 사용하여 스타일화된 시선 추적을 구현하는 방법을 상당히 간단하게 살펴보겠습니다. 개인적으로 나는 이것이 매우 흥미로운 실험이라고 생각했고 작업하는 동안 많은 문제가 발생하지 않았습니다. 그리고 놀랍게도 프로젝트 제출 전 어젯밤에 밤새도록 일할 필요가 없었습니다!</p>

<p>프로젝트 결과나 데모 영상을 자유롭게 확인해 보세요. 여러 사람이 동시에 사용하는 경우 몇 가지 문제가 있을 수 있지만(제대로 하려면 여러 장치와 웹 캠이 필요하므로 테스트하기가 매우 어렵습니다), 그게 해커톤 프로젝트의 방식인 것 같나요? 그리고 테스트해 보면 눈이 보이면 인터넷 어딘가에서 다른 사람이 당신을 지켜보고 있다는 것을 기억하세요!</p>


          

            
        
로그인 후 복사

위 내용은 Supabase 및 WebGazer.js를 사용하여 실시간 시선 추적 경험 구축의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

원천:dev.to
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
저자별 최신 기사
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿