🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付

我編輯了這篇文章,人工智慧幫了我不少忙。這些文章都是關於人工智慧興起的簡短介紹。如果我每天都寫一篇,就沒時間花幾個小時寫一篇了。 😅

AI系列的出現利用了Goose這款開源AI代理。如果你之前沒聽過它,趕快去了解一下吧!

{% embed https://github.com/block/goose %}

在 Goose 的「人工智慧新紀元」第五天活動中,挑戰是搭建「返航指示牌」。這是一個手勢控制的航班到達資訊顯示屏,戴著手套或連指手套的人可以透過手勢進行導航。這樣在嚴寒中就不用碰觸螢幕了。挑戰要求至少有兩種不同的導航手勢,需要真實的航班資料,如果能提供手勢辨識的語音回饋就更好了。

簡而言之:如果你等不及了,我做了一個很酷的東西,你可以在flightboard.nickyt.co上找到它。

技術堆疊

我使用 TanStack Start(React + TypeScript 和 SSR)、 MediaPipe (用於手勢辨識)和OpenSky Network API (用於即時航班資料)建立了它。

說實話,這是我第一次用電腦視覺開發應用,所以這次人工智慧日活動我可是被「科技宅」們「洗腦」了,恨不得把所有相關內容都體驗一遍。電腦視覺這種技術竟然如此容易上手,真是令人驚嘆。這讓我想起了之前和 Gant Laborde 一起做的那場關於人工智慧和 Tensorflow.js 的精彩直播。

{% embed https://www.youtube.com/watch?v=wS5N5R61Z5w

%}

我選擇 TanStack Start 是因為我之前在一個重要的專案-Pomerium MCP 應用示範中已經使用過它。

{% embed https://github.com/pomerium/mcp-app-demo %}

提供 API 端點是避免第三方 API 出現 CORS 問題的經典方法。在本例中,我可以透過自己的伺服器函數代理程式對 OpenSky API 的請求。

以下是我成功實現的功能:

  • 利用 MediaPipe 的 WASM 執行時實現即時手部追蹤

  • 四種手勢類型(握拳、張開手掌、拇指向上、拇指向下)

  • 左右手獨立手勢檢測

  • 透過 TanStack Query 從 OpenSky Network 獲取即時航班資料,並利用智慧快取技術實現。

  • 每個手勢都有語音回饋(可關閉聲音)

  • 一款能夠適應您手部的手勢訓練系統

  • 符合 WCAG AAA 標準的淺色和深色冬季主題

  • 雖然這不是什麼人能輕易做到的事,但我還是加入了選擇相機的功能(如果您有多個攝影機)。

  • 在手機上運作良好

從產品需求文件 (PRD) 開始

我首先編寫了一份產品需求文件(PRD)來規劃工作。這已經成為我應對這類挑戰的必備工具。 PRD 提供了挑戰所需的所有訊息,然後我可以根據這些訊息,結合自己的理解,制定相應的實施方案。

使用 MediaPipe 進行手部追蹤

一開始在瀏覽器中執行 MediaPipe 對我來說有點麻煩。我對 MediaPipe 還不熟悉,嘗試按照基本設定進行操作,但不知為何出了點問題,所以我嘗試了 TensorFlow.js,雖然成功了,但最終還是讓 MediaPipe 執行起來了。

手勢訓練

我之所以選擇 MediaPipe WASM 版本,是因為我想把它部署到 Netlify 上。 WASM 執行時在瀏覽器中執行,這意味著我可以把它託管在任何 PaaS 平台上,而無需擔心 Python 的問題。不過,我知道Vercel 現在也支援 Python 了

// useMediaPipe.ts - Custom hook for MediaPipe integration
const hands = new Hands({
  locateFile: (file) => `/mediapipe/${file}`,
});

hands.setOptions({
  maxNumHands: 2,
  modelComplexity: 1,
  minDetectionConfidence: 0.7,
  minTrackingConfidence: 0.5,
});

手部追蹤以 30-60 幀/秒的速度執行,並帶有地標視覺化功能。我同時鏡像了視訊畫面和地標,因此當您移動手部時,感覺非常自然。

我遇到一個奇怪的問題:綠色的骨骼疊加層(手部特徵點視覺化)竟然和我的手一起出現在我的頭上。 MediaPipe 把臉部特徵辨識成了類似手的形狀。這可能算不上什麼問題,但我透過只在檢測到至少一隻手時才渲染骨骼來修正了我的邏輯。

我還做了超出挑戰要求的額外工作。挑戰要求至少兩種手勢,但我實現了四種:握拳、張開手掌、豎起大拇指和豎起大拇指向下。我還讓它能夠獨立偵測雙手,讓每隻手可以同時做出不同的手勢。

手勢檢測:困難點

手勢檢測比我想像的要複雜得多。我最初的方法是使用基於手指彎曲比例的固定閾值。計算方法很簡單:測量每個指尖到手腕的距離,除以指關節到手腕的距離,即可得到彎曲比例。低於閾值的值表示手指處於彎曲狀態。

// Finger curl ratio: distance(tip, wrist) / distance(knuckle, wrist)
const fingerCurl = (finger: FingerLandmarks) => {
  const tipDist = distance(finger.tip, wrist);
  const knuckleDist = distance(finger.knuckle, wrist);
  return tipDist / knuckleDist;
};

// Gesture classification
const isClosedFist = fingersCurled >= 4 && avgCurl > fistThreshold;
const isOpenPalm = fingersExtended >= 4 && avgCurl < palmThreshold;

在我最初的開發階段,這個方法效果很好。但是當我在不同的光照條件和不同的相機距離下進行測試時,手勢幾乎無法辨識。不同的手部位置和光照條件完全打亂了我設定的閾值。

解決方案是加入手勢訓練模式。使用者重複幾次每個手勢,系統會根據變異數計算個人化閾值。訓練資料變異數越大,閾值就越寬鬆,這樣就能更好地應對人們手勢表現的自然差異。

這原本是挑戰賽的附加功能之一,但結果卻至關重要,而非可有可無。沒有它,手勢偵測功能過於脆弱,根本無法使用。

這是一個典型的過度擬合自身訓練資料的例子。教訓是:建立機器學習系統(即使是簡單的系統)時,請務必考慮變異數。固定間隔適用於演示環境,自適應間隔適用於生產環境。

飛行資料集成

航班資料方面,我使用了OpenSky Network 的免費 API 。它無需身份驗證,因此設定非常簡單。該 API 返回即時航班位置,我使用邊界框篩選出特定機場附近的到達航班。

TanStack Query 處理所有快取和自動刷新邏輯:

// useFlightData.ts
const { data: flights, isLoading, error } = useQuery({
  queryKey: ['flights', 'arrivals'],
  queryFn: fetchFlights,
  // Caching and refetching configuration
  // Cache for 5 minutes in dev, 20 seconds in prod
  staleTime: import.meta.env.DEV ? 300000 : 20000,
  gcTime: 5 * 60 * 1000,    // Cache for 5 min
  refetchInterval: 30_000,   // Auto-refresh every 30s
  retry: 3,                  // Exponential backoff
});

飛行資料

OpenSky Network 有嚴格的速率限制(最小間隔 10 秒),我在開發過程中就遇到過速率限制。因此,我在開發模式下將staleTime設定為 5 分鐘。如果您遇到速率限制,可以使用 VPN 來取得新的 IP 位址,這樣可以延長 API 的使用時間。

生產環境中的staleTime為 20 秒, refetchInterval設定為 30 秒,這樣既能保持資料最新,又不會對 API 造成太大壓力。

音訊回饋

這項挑戰需要為手勢辨識加入音訊回饋,事實證明這至關重要。我為每種手勢加入了不同的聲音:握拳時發出“嗖”的一聲,張開手掌時發出“叮”的一聲,豎起大拇指時發出“叮”的一聲,豎起大拇指向下時發出“嗡嗡”的一聲。

聲音是預先快取的,只有在手勢發生變化時才會播放,而不是每個畫面都播放:

// gestureAudio.ts - Audio caching and playback
const audioCache = new Map<GestureType, HTMLAudioElement>();

export const playGestureSound = (gesture: GestureType) => {
  let audio = audioCache.get(gesture);

  if (!audio) {
    audio = new Audio(GESTURE_SOUNDS[gesture]);
    audio.volume = currentVolume;
    audioCache.set(gesture, audio);
  }

  audio.currentTime = 0; // Reset for quick replay
  audio.play().catch(() => {}); // Ignore autoplay errors
};

您可以在設定中開啟和關閉聲音,這在您反覆測試相同手勢時非常重要。

雖然這只是個額外功能,但我認為這在無障礙存取方面是一項重大進步。想像一下,螢幕閱讀器可以朗讀航班資訊並導航到相應航班,用戶只需豎起大拇指即可表示“就是這個航班!”

航班詳情模態框

用豎起大拇指的手勢選擇航班,即可打開一個詳細的模態框,其中包含所有航班資訊:國旗、呼號、位置、高度、速度、航向和最後聯繫時間。

對於模態介面,我引入了 ShadCN 元件,具體來說是 Dialog 和 Drawer。我之前在類似場景下也使用過它們。 Dialog 負責桌面端的模態體驗,而 Drawer 則提供了行動端流暢的底部向上滑動互動。兩者都內建了完善的輔助功能,這當然是一大優勢。

<div className="fixed inset-0 z-50">
  <div className="absolute inset-0 bg-black/70 backdrop-blur-sm z-0" /> {/* Backdrop */}
  <div className="relative z-10"> {/* Content on top */}
    {/* Modal content */}
  </div>
</div>

在移動端,我將模態框替換為從底部向上滑動的抽屜元件。視窗寬度決定渲染哪個元件。

主題系統

我製作了淺色和深色的冬季主題,與第 4 天的主題非常相似。

{% embed https://dev.to/nickytonline/advent-of-ai-2025-day-4-building-a-winter-festival-website-with-goose-3oac %}

淺色模式採用純白色背景,搭配深冰藍色主色調,對比為 13:1,符合 WCAG AAA 標準。深色模式則採用深藍紫色夜空背景,並帶有明亮的藍色光芒。

主題會儲存到本機儲存中,並在首次載入時遵循系統偏好設定。

定制鉤子:有趣的部分

我特別滿意的一點是自訂鉤子架構。這個專案最終使用了 8 個自訂鉤子,它們讓元件結構更加簡潔。

useMediaPipe負責處理 MediaPipe 的所有初始化和清理工作。它會設定 Hands 實例、配置選項並傳回處理函數。此鉤子封裝了所有 WASM 載入邏輯,因此元件無需了解 MediaPipe 的內部機制。

useWebcam負責管理攝影機存取權和裝置選擇。它處理權限請求,列出可用鏡頭,並將我的攝影機選擇儲存到本機儲存 (localStorage) 中。這一點至關重要,因為我有多台攝像頭,並且需要在筆記型電腦攝影機和外接攝影機之間切換。

useGestures用於實現手勢檢測邏輯。它從 MediaPipe 獲取手部關鍵點,並返回當前手勢類型。此鉤子函數還負責處理防抖動(手勢辨識前需要 300 毫秒的穩定性)以及基於訓練資料的變異數感知閾值計算。

useFlightData封裝了 TanStack Query 用於取得航班資訊。它處理 OpenSky Network API 呼叫,將回應解析為我們的 ProcessedFlight 格式,並管理快取/重新取得間隔。所有航班資料邏輯都隔離在這個鉤子函數中。

useLocalStorage是一個簡單但至關重要的鉤子,用於將狀態與 localStorage 同步。我用它來控制相機選擇、主題偏好和音量設定。狀態一旦改變,就會自動更新。我通常會在大多數 React 專案中加入它。

useWindowFocus可以偵測瀏覽器標籤頁何時失去焦點,以便我們暫停攝影機。這大大節省了電力。如果沒有它,即使我切換標籤頁,MediaPipe 也會繼續處理幀,無謂地消耗 CPU 資源。這不僅節省電量,而且,如果視窗沒有獲得焦點,你也不希望偵測到手勢操作。

useGestureTraining管理手勢訓練流程。我會多次做出每個手勢,這個鉤子會收集手指彎曲資料,計算平均值和標準差,並產生具有方差感知裕度的個人化閾值。

useAudio負責處理所有音效。它會預先快取音訊文件,管理播放,並且只在手勢發生變化時播放聲音(而不是每個畫面都播放)。它還會遵循我的音量設定和靜音開關。

自訂鉤子在 React 應用中非常常見,我對最終使用的鉤子非常滿意。

我學到了什麼

如前所述,這是我第一次開發電腦視覺應用程式,所以有幾點需要學習:

  • 手勢辨識需要進行實際測試並調整閾值。我先前設定的固定閾值在一種光照條件下有效,但在其他光照條件下則無效。即使經過手勢訓練,我認為仍然有一些地方需要改進。

  • 視窗焦點檢測可以節省電量。 MediaPipe 即使在我切換標籤頁時也會持續執行,消耗大量 CPU 資源。當視窗失去焦點時暫停攝影機就解決了這個問題。我一開始沒想到這一點,但除了節省電量之外,它還能防止在不使用應用時偵測到手勢。我在另一個應用程式中看到它執行時才發現這一點。

接下來會發生什麼事?

該應用程式已部署並可在flightboard.nickyt.co上執行。快去體驗一下吧!你可以訓練自己的手勢,並透過手部動作導航航班。程式碼位於我的「2025 年人工智慧展望」程式碼庫中,歡迎查看。

{% embed https://github.com/nickytonline/advent-of-ai-2025 %}

還有一些細節需要完善。手勢偵測可以更可靠,使用者介面也需要改進,還有一些特殊情況我還沒處理。但這就是人工智慧的時代,這意味著是時候迎接下一個挑戰了。

未來潛在的改進方向包括支援多機場、飛行軌跡視覺化、雙手手勢操作以及整合 PartyKit 以實現多人控制。但考慮到這是一個 48 小時的開發版本,我對目前的成果已經很滿意了。

如果你想和我保持聯繫,我的所有社交帳號都在nickyt.online

期待下次!


原文出處:https://dev.to/nickytonline/advent-of-ai-2025-day-5-i-built-a-touchless-flight-tracker-you-control-with-hand-gestures-1jn8


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝17   💬10   ❤️5
425
🥈
我愛JS
📝2   💬8   ❤️4
91
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
🔧 阿川の電商水電行
Shopify 顧問、維護與客製化
💡
小任務 / 單次支援方案
單次處理 Shopify 修正/微調
⭐️
維護方案
每月 Shopify 技術支援 + 小修改 + 諮詢
🚀
專案建置
Shopify 功能導入、培訓 + 分階段交付