ホームページ > ウェブフロントエンド > jsチュートリアル > Supabase と WebGazer.js を使用してリアルタイムの視線追跡エクスペリエンスを構築する

Supabase と WebGazer.js を使用してリアルタイムの視線追跡エクスペリエンスを構築する

Mary-Kate Olsen
リリース: 2024-12-28 11:06:34
オリジナル
548 人が閲覧しました

TL;DR:

  • Supabase、React、WebGazer.js、Motion One、anime.js、Stable Audio で構築
  • Supabase のリアルタイム プレゼンスとブロードキャストを活用します (データベース テーブルはまったく使用されません!)
  • GitHub リポジトリ
  • ウェブサイト
  • デモビデオ

さらに別の Supabase Launch Week Hackathon と、Gaze into the Abyss と呼ばれるもう 1 つの実験的プロジェクトです。 これは、最終的には最も単純なプロジェクトであると同時に、最も複雑なプロジェクトの 1 つとなりました。幸いなことに、私は最近 Cursor をかなり楽しんでいたので、なんとかやり遂げるのにいくつかの助けがありました。また、私の心の中の疑問を検証したいと思いました。データベース テーブルを使わず、Supabase のリアルタイム機能だけを使用することは可能ですか? (多少明白かもしれない) 答えは次のとおりです: はい、そのとおりです (リアルタイム チーム、愛しています ♥️)。それでは、実装についてもう少し詳しく見てみましょう。

アイデア

私はある日、深淵についてのニーチェの名言について考えていました。それを何らかの形で実際に視覚化できたら素敵 (そしてクール) だろう、と考えていました。暗い画面を見つめると、何かがあなたを見つめ返します。それ以上のことは何もありません!

プロジェクトの構築

最初は Three.js を使用してこのプロジェクトを作成しようと考えていましたが、3D の目用に無料のアセットを作成または検索する必要があることに気付きました。特にプロジェクト自体に取り組む時間があまりなかったので、それは少しやりすぎだと判断し、代わりに SVG を使用して 2D で行うことにしました。

また、視覚だけのものにはしたくありませんでした。音声もあればより良い体験になるでしょう。そこで私は、参加者がマイクに向かって話せ、他の人にはそれが不適格なささやき声や通り過ぎる風として聞こえたら素晴らしいだろうというアイデアを思いつきました。しかし、これは非常に困難であることが判明し、WebAudio と WebRTC をうまく接続できなかったため、完全に削除することにしました。コードベースには、ローカルのマイクをリッスンして現在のユーザーに「風の音」をトリガーするコンポーネントが残っていますので、ご覧になりたい場合はご覧ください。将来何か追加されるかもしれません?

リアルタイムルーム

ビジュアル関連の作業をする前に、考えていたリアルタイム設定をテストしたいと思いました。リアルタイム機能にはいくつかの制限があるため、次のように機能するようにしました。

  • 最大数があります。 1 つのチャンネルに一度に 10 人の参加者が参加可能
    • つまり、チャンネルがいっぱいの場合は、新しいチャンネルに参加する必要があります
  • 他の参加者の目だけを見るべきです

このために、次のようなリアルタイム チャネルに再帰的に参加する useEffect セットアップを思いつきました。

ログイン後にコピー
ログイン後にコピー

この joinRoom は useEffect フック内に存在し、ルーム コンポーネントがマウントされるときに呼び出されます。この機能に取り組んでいるときに気づいた注意点の 1 つは、currentPresences パラメータが利用可能であっても、join イベントに値が含まれていないということでした。それが実装のバグなのか、それとも意図したとおりに動作しているのかはわかりません。したがって、ユーザーが参加するたびにルームの参加者の数を取得するには、手動で 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)
    }
  }
}

ログイン後にコピー
ログイン後にコピー

Cursor はこの小さなスロットル関数作成ツールを思いつき、これを次のような視線追跡ブロードキャストで使用しました。

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
})
ログイン後にコピー
ログイン後にコピー

とても役に立ちました!また、初期のバージョンではアイトラッキング メッセージをプレゼンス付きで送信していましたが、ブロードキャストでは 1 秒あたりにより多くのメッセージが許可されるため、代わりに実装をそれに切り替えました。カメラは常にすべてを記録するため、視線追跡では特に重要です。

アイトラッキング

このプロジェクトのアイデアを最初に思いついたとき、しばらく前に WebGazer.js に遭遇しました。これは非常に興味深いプロジェクトで、驚くほどうまくいきます!

視線追跡機能全体は、useEffect フック内の 1 つの関数で実行されます。

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

ログイン後にコピー
ログイン後にコピー

これが実際に動作しているのを見るのは、とても素晴らしい経験でした。同じアプローチを周囲の線にも適用し、それらを中心に向かって「折りたたむ」ように Cursor に指示しました。これはほぼ 1 回の操作で完了しました!

その後、目は、部屋全体が大きな目のように見えるようにセルが配置された単純な 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
})
ログイン後にコピー
ログイン後にコピー

最後の仕上げ

その後、素敵なイントロ画面と BGM を追加すれば、プロジェクトは準備完了です!

このような作業をしているとき、オーディオは常にエクスペリエンスを向上させます。そのため、ユーザーが「深淵に入る」ときに、安定したオーディオを使用して BGM を生成しました。私が音楽に使用したプロンプトは次のとおりです:

アンビエント、不気味、BGM、ささやき声、風、スローテンポ、不気味、深淵

また、ただの黒い画面では少し退屈だと思ったので、背景にアニメーションの SVG フィルターを追加しました。さらに、画面の中央に暗くぼやけた円を追加して、素敵なフェード効果を加えました。おそらく SVG フィルターを使用してこれを行うこともできましたが、これにあまり時間をかけたくありませんでした。次に、さらに動きを持たせるために、背景を軸を中心に回転させました。 SVG フィルターを使用してアニメーションを実行するのは少し不安定な場合があるため、代わりにこの方法で行うことにしました。

 <div>



<h2>
  
  
  結論
</h2>

<p>これで終わりです。Supabase のリアルタイム機能を使用して様式化された視線追跡を実装する方法を非常に簡単に説明しました。個人的には、これは非常に興味深い実験であり、作業中にあまり問題はありませんでした。そして驚くべきことに、プロジェクトを提出する前の最後の夜に徹夜する必要はありませんでした!</p>

<p>プロジェクトやその結果がどうなったかをデモビデオで気軽にチェックしてください。大勢の人が同時にそれを使用している場合、いくつかの問題が発生する可能性があります(適切に実行するには複数のデバイスとウェブカメラが必要なため、テストが非常に困難です)が、それはハッカソンプロジェクトのやり方だと思いますか?そして、それをテストする場合は、目が見えたら、それはインターネット上のどこかで別の誰かがあなたを見ているということを忘れないでください!</p>


          </div>

            
        
ログイン後にコピー

以上がSupabase と WebGazer.js を使用してリアルタイムの視線追跡エクスペリエンスを構築するの詳細内容です。詳細については、PHP 中国語 Web サイトの他の関連記事を参照してください。

ソース:dev.to
このウェブサイトの声明
この記事の内容はネチズンが自主的に寄稿したものであり、著作権は原著者に帰属します。このサイトは、それに相当する法的責任を負いません。盗作または侵害の疑いのあるコンテンツを見つけた場合は、admin@php.cn までご連絡ください。
著者別の最新記事
人気のチュートリアル
詳細>
最新のダウンロード
詳細>
ウェブエフェクト
公式サイト
サイト素材
フロントエンドテンプレート