Skip to content

Commit b2e8bb5

Browse files
committed
feat: add hand tracking overlay and toggle functionality in LiveDemo; update translation API to use Argos Translate
1 parent ce184c5 commit b2e8bb5

3 files changed

Lines changed: 152 additions & 14 deletions

File tree

src/components/LiveDemo.tsx

Lines changed: 119 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ const LiveDemo = ({ mode, onBack, onOpenDataRecorder }: LiveDemoProps) => {
4545
const navigate = useNavigate();
4646
const videoRef = useRef<HTMLVideoElement>(null);
4747
const preProcessCanvasRef = useRef<HTMLCanvasElement | null>(null);
48+
const overlayCanvasRef = useRef<HTMLCanvasElement | null>(null);
4849
const modelRef = useRef<tf.GraphModel | null>(null);
4950
const detectorRef = useRef<handPoseDetection.HandDetector | null>(null);
5051
const labelsRef = useRef<string[]>([]);
@@ -57,6 +58,7 @@ const LiveDemo = ({ mode, onBack, onOpenDataRecorder }: LiveDemoProps) => {
5758
const [handsDetected, setHandsDetected] = useState(false);
5859
const [showTranslator, setShowTranslator] = useState(false);
5960
const [showHelp, setShowHelp] = useState(false);
61+
const [showHandTracking, setShowHandTracking] = useState(true);
6062
const { toast } = useToast();
6163

6264
// Sentence building state
@@ -275,6 +277,74 @@ const LiveDemo = ({ mode, onBack, onOpenDataRecorder }: LiveDemoProps) => {
275277
return { label, score: bestScore };
276278
}, [mode]);
277279

280+
// Draw hand tracking landmarks on overlay canvas
281+
const drawHandLandmarks = useCallback((hands: handPoseDetection.Hand[]) => {
282+
if (!overlayCanvasRef.current || !videoRef.current || !showHandTracking) {
283+
return;
284+
}
285+
286+
const canvas = overlayCanvasRef.current;
287+
const ctx = canvas.getContext('2d');
288+
if (!ctx) return;
289+
290+
// Clear previous drawings
291+
ctx.clearRect(0, 0, canvas.width, canvas.height);
292+
293+
if (!hands.length) return;
294+
295+
// Draw each hand
296+
hands.forEach((hand) => {
297+
const keypoints = hand.keypoints;
298+
if (!keypoints || keypoints.length !== 21) return;
299+
300+
// Draw connections between landmarks
301+
const connections = [
302+
// Thumb
303+
[0, 1], [1, 2], [2, 3], [3, 4],
304+
// Index finger
305+
[0, 5], [5, 6], [6, 7], [7, 8],
306+
// Middle finger
307+
[0, 9], [9, 10], [10, 11], [11, 12],
308+
// Ring finger
309+
[0, 13], [13, 14], [14, 15], [15, 16],
310+
// Pinky
311+
[0, 17], [17, 18], [18, 19], [19, 20],
312+
// Palm
313+
[5, 9], [9, 13], [13, 17]
314+
];
315+
316+
// Draw lines
317+
ctx.strokeStyle = '#00FF00';
318+
ctx.lineWidth = 2;
319+
connections.forEach(([start, end]) => {
320+
const startPoint = keypoints[start];
321+
const endPoint = keypoints[end];
322+
if (startPoint && endPoint) {
323+
ctx.beginPath();
324+
ctx.moveTo(startPoint.x, startPoint.y);
325+
ctx.lineTo(endPoint.x, endPoint.y);
326+
ctx.stroke();
327+
}
328+
});
329+
330+
// Draw keypoints
331+
keypoints.forEach((keypoint, index) => {
332+
// Wrist is larger and different color
333+
if (index === 0) {
334+
ctx.fillStyle = '#FF0000';
335+
ctx.beginPath();
336+
ctx.arc(keypoint.x, keypoint.y, 6, 0, 2 * Math.PI);
337+
ctx.fill();
338+
} else {
339+
ctx.fillStyle = '#00FF00';
340+
ctx.beginPath();
341+
ctx.arc(keypoint.x, keypoint.y, 4, 0, 2 * Math.PI);
342+
ctx.fill();
343+
}
344+
});
345+
});
346+
}, [showHandTracking]);
347+
278348
const computeCropBox = useCallback((hand: handPoseDetection.Hand, frameWidth: number, frameHeight: number): CropBox => {
279349
const points = hand.keypoints ?? [];
280350
if (!points.length) {
@@ -341,6 +411,9 @@ const LiveDemo = ({ mode, onBack, onOpenDataRecorder }: LiveDemoProps) => {
341411
flipHorizontal: false,
342412
});
343413

414+
// Draw hand tracking overlay
415+
drawHandLandmarks(hands);
416+
344417
if (!hands.length) {
345418
setPrediction("—");
346419
setConfidence(0);
@@ -418,7 +491,7 @@ const LiveDemo = ({ mode, onBack, onOpenDataRecorder }: LiveDemoProps) => {
418491
};
419492

420493
detectFrame();
421-
}, [computeCropBox, getTopPrediction, preprocessFrame, preprocessLandmarks, useLandmarks]);
494+
}, [computeCropBox, getTopPrediction, preprocessFrame, preprocessLandmarks, useLandmarks, drawHandLandmarks]);
422495

423496
// Sentence building functions - track how long a letter is held
424497
const updateLetterHold = useCallback((letter: string) => {
@@ -533,6 +606,31 @@ const LiveDemo = ({ mode, onBack, onOpenDataRecorder }: LiveDemoProps) => {
533606
};
534607
}, [mode, isModeEnabled, inputWidth, inputHeight, initCamera, loadModelAssets, loadHandDetector, stopCamera, stopDetection]);
535608

609+
// Setup overlay canvas dimensions when video is ready
610+
useEffect(() => {
611+
if (videoRef.current && overlayCanvasRef.current && isCameraReady) {
612+
const video = videoRef.current;
613+
const canvas = overlayCanvasRef.current;
614+
615+
const updateCanvasSize = () => {
616+
canvas.width = video.videoWidth;
617+
canvas.height = video.videoHeight;
618+
};
619+
620+
// Set initial size
621+
if (video.videoWidth > 0) {
622+
updateCanvasSize();
623+
}
624+
625+
// Update on video metadata loaded
626+
video.addEventListener('loadedmetadata', updateCanvasSize);
627+
628+
return () => {
629+
video.removeEventListener('loadedmetadata', updateCanvasSize);
630+
};
631+
}
632+
}, [isCameraReady]);
633+
536634
useEffect(() => {
537635
if (!isModeEnabled) {
538636
return;
@@ -636,6 +734,12 @@ const LiveDemo = ({ mode, onBack, onOpenDataRecorder }: LiveDemoProps) => {
636734
style={{ transform: "scaleX(-1)" }}
637735
muted
638736
/>
737+
{/* Hand tracking overlay canvas */}
738+
<canvas
739+
ref={overlayCanvasRef}
740+
className="absolute top-0 left-0 w-full h-full pointer-events-none scale-x-[-1]"
741+
style={{ transform: "scaleX(-1)" }}
742+
/>
639743
{!isCameraReady && (
640744
<div className="absolute inset-0 flex items-center justify-center bg-muted/80 z-10">
641745
<p className="text-lg text-muted-foreground">
@@ -654,11 +758,22 @@ const LiveDemo = ({ mode, onBack, onOpenDataRecorder }: LiveDemoProps) => {
654758
</div>
655759
)}
656760
{isCameraReady && isModelReady && isDetectorReady && (
657-
<div className={`absolute top-4 right-4 px-3 py-2 rounded-lg text-sm z-10 transition-colors ${
761+
<>
762+
<div className={`absolute top-4 right-4 px-3 py-2 rounded-lg text-sm z-10 transition-colors ${
658763
handsDetected ? 'bg-pastel-green text-gray-800' : 'bg-muted text-muted-foreground'
659764
}`}>
660765
{handsDetected ? '✋ Hand Detected' : '👋 Show your hand'}
661766
</div>
767+
{/* Hand tracking toggle */}
768+
<Button
769+
variant="outline"
770+
size="sm"
771+
onClick={() => setShowHandTracking(!showHandTracking)}
772+
className="absolute top-4 left-4 z-10 bg-background/80 backdrop-blur-sm"
773+
>
774+
{showHandTracking ? '👁️ Hide Tracking' : '👁️ Show Tracking'}
775+
</Button>
776+
</>
662777
)}
663778
{!isModeEnabled && (
664779
<div className="absolute inset-0 flex items-center justify-center bg-muted/80 z-10">
@@ -795,6 +910,8 @@ const LiveDemo = ({ mode, onBack, onOpenDataRecorder }: LiveDemoProps) => {
795910
<li>• Keep your hand clearly visible in the camera frame</li>
796911
<li>• Position yourself 1-2 feet from the camera</li>
797912
<li>• Good lighting helps improve detection accuracy</li>
913+
<li><strong>Green dots and lines show hand tracking in real-time</strong></li>
914+
<li>• Use the "Show/Hide Tracking" button to toggle hand landmarks</li>
798915
<li><strong>Hold each gesture for 3 seconds</strong> to capture the letter</li>
799916
<li>• Watch the progress bar fill up as you hold the gesture</li>
800917
<li>• To capture the same letter twice (e.g., "HELLO"), briefly remove your hand between captures</li>

src/components/TranslatorDialog.tsx

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -58,36 +58,55 @@ const TranslatorDialog = ({
5858

5959
setIsTranslating(true);
6060
try {
61+
// Prepare request body for LibreTranslate
62+
const requestBody: any = {
63+
q: text,
64+
source: config.translation.defaultSourceLanguage,
65+
target: targetLanguage,
66+
format: "text",
67+
};
68+
69+
// Only add API key if it's provided
70+
if (config.translation.apiKey) {
71+
requestBody.api_key = config.translation.apiKey;
72+
}
73+
6174
const response = await fetch(config.translation.apiEndpoint, {
6275
method: "POST",
6376
headers: {
6477
"Content-Type": "application/json",
6578
},
66-
body: JSON.stringify({
67-
q: text,
68-
source: config.translation.defaultSourceLanguage,
69-
target: targetLanguage,
70-
format: "text",
71-
api_key: config.translation.apiKey,
72-
}),
79+
body: JSON.stringify(requestBody),
7380
});
7481

7582
if (!response.ok) {
76-
throw new Error("Translation failed");
83+
const errorData = await response.json().catch(() => ({}));
84+
throw new Error(errorData.error || `HTTP ${response.status}: ${response.statusText}`);
7785
}
7886

7987
const data = await response.json();
80-
setTranslatedText(data.translatedText || data.translation);
88+
89+
// LibreTranslate returns translatedText field
90+
const translation = data.translatedText || data.translation || "";
91+
92+
if (!translation) {
93+
throw new Error("No translation returned from API");
94+
}
95+
96+
setTranslatedText(translation);
8197

8298
toast({
8399
title: "Translated",
84100
description: `Translated to ${LANGUAGES.find(l => l.code === targetLanguage)?.name}`,
85101
});
86102
} catch (error) {
87103
console.error("Translation error:", error);
104+
const errorMessage = error instanceof Error ? error.message : "Unknown error";
88105
toast({
89106
title: "Translation Failed",
90-
description: "Please check your translation API configuration",
107+
description: errorMessage.includes("CORS") || errorMessage.includes("Failed to fetch")
108+
? "Network error. Try using a self-hosted LibreTranslate instance."
109+
: errorMessage,
91110
variant: "destructive",
92111
});
93112
} finally {

src/config.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,10 @@ const SIGN_LANGUAGE_ALPHABET = [
7272
export const config: AppConfig = {
7373
translation: {
7474
enabled: true,
75-
apiEndpoint: "https://libretranslate.com/translate",
76-
apiKey: "",
75+
// Using Argos Translate - free, no API key required, CORS-friendly
76+
// Alternative: Get free API key from https://portal.libretranslate.com
77+
apiEndpoint: "https://translate.argosopentech.com/translate",
78+
apiKey: "", // No API key needed for Argos Translate
7779
defaultSourceLanguage: "en",
7880
defaultTargetLanguage: "es",
7981
},

0 commit comments

Comments
 (0)