|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>Webcam Eye Tracker</title> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
|
|
<style> |
|
|
:root { |
|
|
--primary-color: #5d69b2; |
|
|
--secondary-color: #3a416f; |
|
|
--accent-color: #ff7e5f; |
|
|
--bg-color: #f5f7fa; |
|
|
--text-color: #333; |
|
|
} |
|
|
|
|
|
* { |
|
|
margin: 0; |
|
|
padding: 0; |
|
|
box-sizing: border-box; |
|
|
} |
|
|
|
|
|
body { |
|
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; |
|
|
background-color: var(--bg-color); |
|
|
color: var(--text-color); |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
min-height: 100vh; |
|
|
padding: 2rem; |
|
|
position: relative; |
|
|
overflow-x: hidden; |
|
|
} |
|
|
|
|
|
header { |
|
|
text-align: center; |
|
|
margin-bottom: 2rem; |
|
|
width: 100%; |
|
|
} |
|
|
|
|
|
h1 { |
|
|
color: var(--primary-color); |
|
|
margin-bottom: 0.5rem; |
|
|
font-size: 2.2rem; |
|
|
} |
|
|
|
|
|
.subtitle { |
|
|
color: var(--secondary-color); |
|
|
opacity: 0.8; |
|
|
margin-bottom: 1.5rem; |
|
|
} |
|
|
|
|
|
.tracker-container { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
width: 100%; |
|
|
max-width: 800px; |
|
|
gap: 2rem; |
|
|
} |
|
|
|
|
|
.camera-container { |
|
|
position: relative; |
|
|
width: 100%; |
|
|
max-width: 640px; |
|
|
border-radius: 12px; |
|
|
overflow: hidden; |
|
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); |
|
|
} |
|
|
|
|
|
#video { |
|
|
width: 100%; |
|
|
display: block; |
|
|
background-color: #000; |
|
|
} |
|
|
|
|
|
#canvas { |
|
|
position: absolute; |
|
|
top: 0; |
|
|
left: 0; |
|
|
width: 100%; |
|
|
} |
|
|
|
|
|
.metrics-container { |
|
|
display: flex; |
|
|
flex-wrap: wrap; |
|
|
gap: 1rem; |
|
|
justify-content: center; |
|
|
width: 100%; |
|
|
} |
|
|
|
|
|
.metric-card { |
|
|
background-color: white; |
|
|
border-radius: 12px; |
|
|
padding: 1.5rem; |
|
|
min-width: 200px; |
|
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05); |
|
|
flex-grow: 1; |
|
|
transition: transform 0.3s ease, box-shadow 0.3s ease; |
|
|
} |
|
|
|
|
|
.metric-card:hover { |
|
|
transform: translateY(-5px); |
|
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); |
|
|
} |
|
|
|
|
|
.metric-title { |
|
|
color: var(--primary-color); |
|
|
font-size: 0.9rem; |
|
|
text-transform: uppercase; |
|
|
letter-spacing: 1px; |
|
|
margin-bottom: 0.5rem; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
} |
|
|
|
|
|
.metric-value { |
|
|
font-size: 1.8rem; |
|
|
font-weight: bold; |
|
|
color: var(--secondary-color); |
|
|
} |
|
|
|
|
|
.metric-unit { |
|
|
font-size: 0.9rem; |
|
|
color: #888; |
|
|
margin-left: 0.3rem; |
|
|
} |
|
|
|
|
|
.chart-container { |
|
|
width: 100%; |
|
|
height: 200px; |
|
|
background-color: white; |
|
|
border-radius: 12px; |
|
|
padding: 1rem; |
|
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05); |
|
|
position: relative; |
|
|
overflow: hidden; |
|
|
} |
|
|
|
|
|
canvas#gaze-chart { |
|
|
width: 100%; |
|
|
height: 100%; |
|
|
} |
|
|
|
|
|
.controls { |
|
|
display: flex; |
|
|
gap: 1rem; |
|
|
margin-top: 1rem; |
|
|
flex-wrap: wrap; |
|
|
justify-content: center; |
|
|
} |
|
|
|
|
|
button { |
|
|
padding: 0.8rem 1.5rem; |
|
|
background-color: var(--primary-color); |
|
|
color: white; |
|
|
border: none; |
|
|
border-radius: 8px; |
|
|
cursor: pointer; |
|
|
font-weight: 600; |
|
|
transition: all 0.3s ease; |
|
|
display: flex; |
|
|
align-items: center; |
|
|
gap: 0.5rem; |
|
|
} |
|
|
|
|
|
button:hover { |
|
|
background-color: var(--secondary-color); |
|
|
transform: translateY(-2px); |
|
|
} |
|
|
|
|
|
button.secondary { |
|
|
background-color: white; |
|
|
color: var(--primary-color); |
|
|
border: 1px solid #ddd; |
|
|
} |
|
|
|
|
|
button.secondary:hover { |
|
|
background-color: #f0f0f0; |
|
|
} |
|
|
|
|
|
.loading { |
|
|
display: flex; |
|
|
flex-direction: column; |
|
|
align-items: center; |
|
|
justify-content: center; |
|
|
gap: 1rem; |
|
|
padding: 2rem; |
|
|
background-color: white; |
|
|
border-radius: 12px; |
|
|
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05); |
|
|
width: 100%; |
|
|
} |
|
|
|
|
|
.spinner { |
|
|
border: 4px solid rgba(0, 0, 0, 0.1); |
|
|
border-radius: 50%; |
|
|
border-top: 4px solid var(--primary-color); |
|
|
width: 40px; |
|
|
height: 40px; |
|
|
animation: spin 1s linear infinite; |
|
|
} |
|
|
|
|
|
@keyframes spin { |
|
|
0% { transform: rotate(0deg); } |
|
|
100% { transform: rotate(360deg); } |
|
|
} |
|
|
|
|
|
.error-message { |
|
|
color: #e74c3c; |
|
|
background-color: #fceae9; |
|
|
padding: 1rem; |
|
|
border-radius: 8px; |
|
|
max-width: 100%; |
|
|
text-align: center; |
|
|
} |
|
|
|
|
|
footer { |
|
|
margin-top: 3rem; |
|
|
text-align: center; |
|
|
color: #888; |
|
|
font-size: 0.9rem; |
|
|
} |
|
|
|
|
|
@media (max-width: 600px) { |
|
|
.tracker-container { |
|
|
gap: 1rem; |
|
|
} |
|
|
|
|
|
.metric-card { |
|
|
min-width: 150px; |
|
|
padding: 1rem; |
|
|
} |
|
|
|
|
|
.metric-value { |
|
|
font-size: 1.5rem; |
|
|
} |
|
|
} |
|
|
</style> |
|
|
</head> |
|
|
<body> |
|
|
<header> |
|
|
<h1><i class="fas fa-eye"></i> Webcam Eye Tracker</h1> |
|
|
<p class="subtitle">Real-time eye tracking using your webcam and face detection</p> |
|
|
</header> |
|
|
|
|
|
<div class="tracker-container"> |
|
|
<div class="loading" id="loading"> |
|
|
<div class="spinner"></div> |
|
|
<p>Loading face detection models...</p> |
|
|
</div> |
|
|
|
|
|
<div class="camera-container" id="camera-container" style="display: none;"> |
|
|
<video id="video" width="640" height="480" autoplay muted playsinline></video> |
|
|
<canvas id="canvas" width="640" height="480"></canvas> |
|
|
</div> |
|
|
|
|
|
<div class="metrics-container"> |
|
|
<div class="metric-card"> |
|
|
<div class="metric-title"> |
|
|
<i class="fas fa-crosshairs"></i> Eye Position |
|
|
</div> |
|
|
<div class="metric-value" id="eye-position"> |
|
|
<span id="eye-x">0</span>, <span id="eye-y">0</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="metric-card"> |
|
|
<div class="metric-title"> |
|
|
<i class="fas fa-running"></i> Movement Speed |
|
|
</div> |
|
|
<div class="metric-value" id="movement-speed">0<span class="metric-unit">px/s</span></div> |
|
|
</div> |
|
|
|
|
|
<div class="metric-card"> |
|
|
<div class="metric-title"> |
|
|
<i class="fas fa-history"></i> Time Tracked |
|
|
</div> |
|
|
<div class="metric-value" id="time-tracked">0<span class="metric-unit">s</span></div> |
|
|
</div> |
|
|
|
|
|
<div class="metric-card"> |
|
|
<div class="metric-title"> |
|
|
<i class="fas fa-bullseye"></i> Fixations |
|
|
</div> |
|
|
<div class="metric-value" id="fixation-count">0</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="chart-container"> |
|
|
<canvas id="gaze-chart"></canvas> |
|
|
</div> |
|
|
|
|
|
<div class="controls"> |
|
|
<button id="start-btn"><i class="fas fa-play"></i> Start Tracking</button> |
|
|
<button id="reset-btn" class="secondary"><i class="fas fa-redo"></i> Reset</button> |
|
|
<button id="debug-btn" class="secondary"><i class="fas fa-bug"></i> Toggle Debug</button> |
|
|
</div> |
|
|
|
|
|
<div class="error-message" id="error-message" style="display: none;"></div> |
|
|
</div> |
|
|
|
|
|
<footer> |
|
|
<p>Webcam Eye Tracker © 2024 | Uses face-api.js for face and eye detection</p> |
|
|
</footer> |
|
|
|
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/face-api.js@0.22.2/dist/face-api.min.js"></script> |
|
|
|
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> |
|
|
|
|
|
<script> |
|
|
document.addEventListener('DOMContentLoaded', async function() { |
|
|
|
|
|
const video = document.getElementById('video'); |
|
|
const canvas = document.getElementById('canvas'); |
|
|
const ctx = canvas.getContext('2d'); |
|
|
const startBtn = document.getElementById('start-btn'); |
|
|
const resetBtn = document.getElementById('reset-btn'); |
|
|
const debugBtn = document.getElementById('debug-btn'); |
|
|
const eyeX = document.getElementById('eye-x'); |
|
|
const eyeY = document.getElementById('eye-y'); |
|
|
const movementSpeed = document.getElementById('movement-speed'); |
|
|
const timeTracked = document.getElementById('time-tracked'); |
|
|
const fixationCount = document.getElementById('fixation-count'); |
|
|
const loadingElement = document.getElementById('loading'); |
|
|
const cameraContainer = document.getElementById('camera-container'); |
|
|
const errorMessage = document.getElementById('error-message'); |
|
|
|
|
|
|
|
|
let trackingActive = false; |
|
|
let startTime = 0; |
|
|
let lastPosition = { x: 0, y: 0 }; |
|
|
let lastTime = 0; |
|
|
let currentSpeed = 0; |
|
|
let fixations = 0; |
|
|
let fixationStartTime = 0; |
|
|
let isFixated = false; |
|
|
let gazeHistory = []; |
|
|
let showDebug = false; |
|
|
let modelsLoaded = false; |
|
|
let stream = null; |
|
|
|
|
|
|
|
|
const chartCtx = document.getElementById('gaze-chart').getContext('2d'); |
|
|
const gazeChart = new Chart(chartCtx, { |
|
|
type: 'line', |
|
|
data: { |
|
|
labels: [], |
|
|
datasets: [{ |
|
|
label: 'Eye Movement Speed (px/s)', |
|
|
data: [], |
|
|
borderColor: '#5d69b2', |
|
|
backgroundColor: 'rgba(93, 105, 178, 0.1)', |
|
|
tension: 0.4, |
|
|
fill: true |
|
|
}] |
|
|
}, |
|
|
options: { |
|
|
responsive: true, |
|
|
maintainAspectRatio: false, |
|
|
scales: { |
|
|
y: { |
|
|
beginAtZero: true |
|
|
} |
|
|
}, |
|
|
animation: { |
|
|
duration: 0 |
|
|
} |
|
|
} |
|
|
}); |
|
|
|
|
|
|
|
|
async function loadModels() { |
|
|
try { |
|
|
await faceapi.nets.tinyFaceDetector.loadFromUri('https://justadudewhohacks.github.io/face-api.js/models'); |
|
|
await faceapi.nets.faceLandmark68Net.loadFromUri('https://justadudewhohacks.github.io/face-api.js/models'); |
|
|
modelsLoaded = true; |
|
|
|
|
|
|
|
|
loadingElement.style.display = 'none'; |
|
|
cameraContainer.style.display = 'block'; |
|
|
|
|
|
|
|
|
initCamera(); |
|
|
} catch (error) { |
|
|
console.error('Error loading models:', error); |
|
|
showError("Failed to load face detection models. Please check your internet connection and try again."); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
async function initCamera() { |
|
|
try { |
|
|
stream = await navigator.mediaDevices.getUserMedia({ |
|
|
video: { |
|
|
width: { ideal: 640 }, |
|
|
height: { ideal: 480 }, |
|
|
facingMode: 'user' |
|
|
}, |
|
|
audio: false |
|
|
}); |
|
|
video.srcObject = stream; |
|
|
video.play(); |
|
|
} catch (error) { |
|
|
console.error('Camera error:', error); |
|
|
if (error.name === 'NotAllowedError') { |
|
|
showError("Camera access was denied. Please allow camera access to use this feature."); |
|
|
} else if (error.name === 'NotFoundError') { |
|
|
showError("No camera found. Please connect a webcam to use this feature."); |
|
|
} else { |
|
|
showError("Failed to access camera. Please try again."); |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
function showError(message) { |
|
|
errorMessage.textContent = message; |
|
|
errorMessage.style.display = 'block'; |
|
|
loadingElement.style.display = 'none'; |
|
|
} |
|
|
|
|
|
|
|
|
function getEyePosition(landmarks) { |
|
|
if (!landmarks || !landmarks.getLeftEye || !landmarks.getRightEye) { |
|
|
return { x: 0, y: 0 }; |
|
|
} |
|
|
|
|
|
const leftEye = landmarks.getLeftEye(); |
|
|
const rightEye = landmarks.getRightEye(); |
|
|
|
|
|
|
|
|
const leftEyeCenter = leftEye.reduce((sum, point) => { |
|
|
return { x: sum.x + point.x, y: sum.y + point.y }; |
|
|
}, { x: 0, y: 0 }); |
|
|
|
|
|
const rightEyeCenter = rightEye.reduce((sum, point) => { |
|
|
return { x: sum.x + point.x, y: sum.y + point.y }; |
|
|
}, { x: 0, y: 0 }); |
|
|
|
|
|
leftEyeCenter.x /= leftEye.length; |
|
|
leftEyeCenter.y /= leftEye.length; |
|
|
rightEyeCenter.x /= rightEye.length; |
|
|
rightEyeCenter.y /= rightEye.length; |
|
|
|
|
|
|
|
|
return { |
|
|
x: (leftEyeCenter.x + rightEyeCenter.x) / 2, |
|
|
y: (leftEyeCenter.y + rightEyeCenter.y) / 2 |
|
|
}; |
|
|
} |
|
|
|
|
|
|
|
|
function updateMetrics(position) { |
|
|
const now = Date.now(); |
|
|
const timeElapsed = (now - lastTime) / 1000; |
|
|
|
|
|
if (timeElapsed > 0) { |
|
|
const dx = position.x - lastPosition.x; |
|
|
const dy = position.y - lastPosition.y; |
|
|
const distance = Math.sqrt(dx * dx + dy * dy); |
|
|
currentSpeed = distance / timeElapsed; |
|
|
|
|
|
|
|
|
if (distance < 15) { |
|
|
if (!isFixated) { |
|
|
isFixated = true; |
|
|
fixationStartTime = now; |
|
|
} |
|
|
|
|
|
|
|
|
if (isFixated && now - fixationStartTime > 200) { |
|
|
fixations++; |
|
|
fixationCount.textContent = fixations; |
|
|
isFixated = false; |
|
|
} |
|
|
} else { |
|
|
isFixated = false; |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
movementSpeed.textContent = Math.round(currentSpeed); |
|
|
timeTracked.textContent = Math.round((now - startTime) / 1000); |
|
|
eyeX.textContent = Math.round(position.x); |
|
|
eyeY.textContent = Math.round(position.y); |
|
|
|
|
|
|
|
|
if (gazeHistory.length > 50) { |
|
|
gazeHistory.shift(); |
|
|
gazeChart.data.labels.shift(); |
|
|
gazeChart.data.datasets[0].data.shift(); |
|
|
} |
|
|
|
|
|
gazeHistory.push(currentSpeed); |
|
|
gazeChart.data.labels.push(''); |
|
|
gazeChart.data.datasets[0].data.push(currentSpeed); |
|
|
gazeChart.update(); |
|
|
|
|
|
|
|
|
lastPosition = position; |
|
|
lastTime = now; |
|
|
} |
|
|
|
|
|
|
|
|
async function processVideo() { |
|
|
if (!trackingActive || !modelsLoaded) { |
|
|
requestAnimationFrame(processVideo); |
|
|
return; |
|
|
} |
|
|
|
|
|
try { |
|
|
|
|
|
const options = new faceapi.TinyFaceDetectorOptions({ |
|
|
inputSize: 128, |
|
|
scoreThreshold: 0.5 |
|
|
}); |
|
|
|
|
|
const result = await faceapi.detectSingleFace(video, options) |
|
|
.withFaceLandmarks(); |
|
|
|
|
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height); |
|
|
|
|
|
if (result) { |
|
|
const { landmarks, detection } = result; |
|
|
|
|
|
|
|
|
const eyePosition = getEyePosition(landmarks); |
|
|
|
|
|
|
|
|
updateMetrics(eyePosition); |
|
|
|
|
|
|
|
|
if (showDebug) { |
|
|
|
|
|
const box = detection.box; |
|
|
ctx.strokeStyle = '#00FF00'; |
|
|
ctx.lineWidth = 2; |
|
|
ctx.strokeRect(box.x, box.y, box.width, box.height); |
|
|
|
|
|
|
|
|
faceapi.draw.drawFaceLandmarks(canvas, landmarks); |
|
|
|
|
|
|
|
|
ctx.fillStyle = '#FF0000'; |
|
|
ctx.beginPath(); |
|
|
ctx.arc(eyePosition.x, eyePosition.y, 5, 0, 2 * Math.PI); |
|
|
ctx.fill(); |
|
|
} |
|
|
} |
|
|
} catch (error) { |
|
|
console.error('Detection error:', error); |
|
|
} |
|
|
|
|
|
requestAnimationFrame(processVideo); |
|
|
} |
|
|
|
|
|
|
|
|
function startTracking() { |
|
|
if (!modelsLoaded) { |
|
|
showError("Face detection models not loaded yet. Please wait."); |
|
|
return; |
|
|
} |
|
|
|
|
|
trackingActive = true; |
|
|
startTime = Date.now(); |
|
|
lastTime = Date.now(); |
|
|
startBtn.innerHTML = '<i class="fas fa-pause"></i> Pause Tracking'; |
|
|
startBtn.style.backgroundColor = '#ff7e5f'; |
|
|
|
|
|
|
|
|
processVideo(); |
|
|
} |
|
|
|
|
|
|
|
|
function pauseTracking() { |
|
|
trackingActive = false; |
|
|
startBtn.innerHTML = '<i class="fas fa-play"></i> Resume Tracking'; |
|
|
startBtn.style.backgroundColor = '#5d69b2'; |
|
|
} |
|
|
|
|
|
|
|
|
function resetTracking() { |
|
|
pauseTracking(); |
|
|
startTime = 0; |
|
|
currentSpeed = 0; |
|
|
fixations = 0; |
|
|
gazeHistory = []; |
|
|
|
|
|
|
|
|
eyeX.textContent = '0'; |
|
|
eyeY.textContent = '0'; |
|
|
movementSpeed.textContent = '0'; |
|
|
timeTracked.textContent = '0'; |
|
|
fixationCount.textContent = '0'; |
|
|
|
|
|
|
|
|
gazeChart.data.labels = []; |
|
|
gazeChart.data.datasets[0].data = []; |
|
|
gazeChart.update(); |
|
|
|
|
|
|
|
|
ctx.clearRect(0, 0, canvas.width, canvas.height); |
|
|
} |
|
|
|
|
|
|
|
|
startBtn.addEventListener('click', function() { |
|
|
if (trackingActive) { |
|
|
pauseTracking(); |
|
|
} else { |
|
|
startTracking(); |
|
|
} |
|
|
}); |
|
|
|
|
|
resetBtn.addEventListener('click', resetTracking); |
|
|
|
|
|
debugBtn.addEventListener('click', function() { |
|
|
showDebug = !showDebug; |
|
|
debugBtn.innerHTML = showDebug ? |
|
|
'<i class="fas fa-eye-slash"></i> Hide Debug' : |
|
|
'<i class="fas fa-eye"></i> Show Debug'; |
|
|
}); |
|
|
|
|
|
|
|
|
loadModels(); |
|
|
}); |
|
|
</script> |
|
|
</body> |
|
|
</html> |