yolo-vision-tracker / index.html
NV9523's picture
tôi cần vẽ được cả trên iphone máy cảm ứng và sửa để khi vẽ thì có thể thấy line đã vẽ
9a61652 verified
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Camera Detection - YOLO</title>
<script src="https://cdn.tailwindcss.com"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
@keyframes fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in { animation: fade-in 0.5s ease-out; }
.animate-pulse-slow { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
#outputCanvas { cursor: default; }
#outputCanvas.drawing { cursor: crosshair; }
/* Dark mode styles */
@media (prefers-color-scheme: dark) {
body { background-color: #0f172a; }
}
</style>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
'rix': {
50: '#eff6ff',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
}
}
}
}
}
</script>
</head>
<body class="bg-slate-100 dark:bg-slate-900 min-h-screen p-4 md:p-8">
<div class="max-w-7xl mx-auto space-y-6 animate-fade-in pb-20">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<h1 class="text-3xl font-bold text-slate-800 dark:text-white border-l-4 border-rix-600 pl-4">
📹 Camera Detection
</h1>
</div>
<!-- Main Container -->
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-100 dark:border-slate-700 p-6">
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Camera View Section -->
<div class="lg:col-span-2 flex flex-col items-center space-y-6">
<!-- Controls -->
<div class="flex flex-wrap items-center justify-center gap-4 bg-slate-50 dark:bg-slate-700/50 p-4 rounded-2xl w-full">
<!-- Device Selector -->
<div class="flex items-center gap-2">
<svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<select id="cameraSelect" class="bg-white dark:bg-slate-600 text-slate-800 dark:text-slate-200 text-sm rounded-md border-none py-2 px-3 focus:ring-2 focus:ring-rix-500">
<option value="">-- Auto Select --</option>
</select>
</div>
<div class="w-px h-8 bg-slate-300 dark:bg-slate-600 mx-2 hidden sm:block"></div>
<!-- Play/Stop -->
<div class="flex items-center gap-2">
<button id="startBtn" class="bg-emerald-500 hover:bg-emerald-600 text-white p-2 rounded-full transition-colors shadow-sm">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z"/>
</svg>
</button>
<button id="stopBtn" disabled class="bg-red-500 hover:bg-red-600 text-white p-2 rounded-full transition-colors shadow-sm disabled:opacity-50">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
</svg>
</button>
</div>
<div class="w-px h-8 bg-slate-300 dark:bg-slate-600 mx-2 hidden sm:block"></div>
<!-- Draw & Grid Controls -->
<div class="flex items-center gap-2">
<button id="toggleGridBtn" disabled class="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-bold bg-white dark:bg-slate-600 text-slate-600 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-500 disabled:opacity-50 transition-all">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-3zM14 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1v-3z"></path>
</svg>
Show Grid
</button>
<button id="drawLineBtn" disabled class="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-bold bg-white dark:bg-slate-600 text-slate-600 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-500 disabled:opacity-50 transition-all">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
Draw Line
</button>
<button id="clearLineBtn" class="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-bold bg-white dark:bg-slate-600 text-red-500 hover:bg-red-50 dark:hover:bg-slate-500 transition-all border border-transparent hover:border-red-200">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
Clear
</button>
<button id="resetCountBtn" class="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-bold bg-slate-200 hover:bg-slate-300 dark:bg-slate-600 dark:hover:bg-slate-500 text-slate-700 dark:text-white transition-all">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
Reset
</button>
</div>
</div>
<!-- Hint Box -->
<div id="hintText" class="w-full text-center text-sm p-3 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg border border-blue-200 dark:border-blue-800">
Click "Start Camera" to begin. Then "Show Grid" and "Draw Line" to set counting line.
</div>
<!-- Video Viewport -->
<div class="relative w-full bg-black rounded-lg overflow-hidden shadow-2xl aspect-video group select-none">
<video id="videoInput" class="hidden" autoplay playsinline muted></video>
<canvas id="outputCanvas" class="w-full h-full object-cover"></canvas>
<!-- Live Indicator -->
<div id="liveIndicator" class="hidden absolute top-4 right-4 flex items-center gap-2 bg-black/60 px-3 py-1 rounded-full backdrop-blur-md pointer-events-none">
<div class="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
<span class="text-white text-xs font-bold tracking-wider">LIVE STREAM</span>
</div>
<!-- Drawing Mode Indicator -->
<div id="drawingIndicator" class="hidden absolute top-4 left-4 bg-amber-500 text-white text-xs font-bold px-3 py-1 rounded-md shadow-lg pointer-events-none animate-pulse">
Drawing Mode Active
</div>
<!-- Placeholder -->
<div id="cameraPlaceholder" class="absolute inset-0 flex items-center justify-center bg-black/50 text-white/50">
<svg class="w-16 h-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
</div>
</div>
</div>
<!-- Stats Section -->
<div class="lg:col-span-1">
<h3 class="font-bold text-slate-800 dark:text-white mb-2 flex justify-between items-center">
<span>Live Session Stats</span>
<span id="statusIndicator" class="text-xs text-slate-400 font-normal">Ready</span>
</h3>
<!-- Counter Card -->
<div class="bg-white dark:bg-slate-700/50 p-4 rounded-xl border border-slate-200 dark:border-slate-600 mb-4 shadow-sm">
<div class="text-xs text-slate-500 dark:text-slate-400 uppercase font-bold tracking-wider mb-1">Line Cross Count</div>
<div class="text-3xl font-bold text-rix-600 dark:text-white" id="totalCount">0</div>
<div class="mt-2 text-xs text-slate-400" id="lineInfo">No lines drawn</div>
</div>
<!-- FPS Card -->
<div class="bg-white dark:bg-slate-700/50 p-4 rounded-xl border border-slate-200 dark:border-slate-600 mb-4 shadow-sm">
<div class="text-xs text-slate-500 dark:text-slate-400 uppercase font-bold tracking-wider mb-1">FPS</div>
<div class="text-3xl font-bold text-emerald-600 dark:text-emerald-400" id="fpsValue">0</div>
</div>
<!-- Chart -->
<div class="bg-slate-50 dark:bg-slate-700/50 p-4 rounded-xl border border-slate-200 dark:border-slate-600">
<h4 class="text-sm font-bold text-slate-700 dark:text-slate-300 mb-4 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
Count History
</h4>
<div class="h-48 w-full">
<canvas id="countChart"></canvas>
</div>
</div>
<p class="text-xs text-slate-400 mt-4">
* Counts are cumulative for the current session.
</p>
</div>
</div>
</div>
</div>
<script>
// Constants
const API_URL = window.location.origin;
// Elements
const video = document.getElementById('videoInput');
const canvas = document.getElementById('outputCanvas');
const ctx = canvas.getContext('2d');
const startBtn = document.getElementById('startBtn');
const stopBtn = document.getElementById('stopBtn');
const drawLineBtn = document.getElementById('drawLineBtn');
const toggleGridBtn = document.getElementById('toggleGridBtn');
const resetCountBtn = document.getElementById('resetCountBtn');
const clearLineBtn = document.getElementById('clearLineBtn');
const cameraSelect = document.getElementById('cameraSelect');
const hintText = document.getElementById('hintText');
const liveIndicator = document.getElementById('liveIndicator');
const drawingIndicator = document.getElementById('drawingIndicator');
const cameraPlaceholder = document.getElementById('cameraPlaceholder');
const statusIndicator = document.getElementById('statusIndicator');
const lineInfo = document.getElementById('lineInfo');
// State
let ws = null;
let stream = null;
let animationId = null;
let isDrawingMode = false;
let showGrid = false;
let linePoints = [];
let tempLine = null;
let currentLine = null;
let devices = [];
// Chart setup
const countChart = new Chart(document.getElementById('countChart'), {
type: 'line',
data: {
labels: [],
datasets: [{
label: 'Count',
data: [],
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.4,
fill: true,
pointRadius: 3,
pointHoverRadius: 5
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false }
},
scales: {
y: {
beginAtZero: true,
ticks: { color: '#94a3b8' },
grid: { color: 'rgba(148, 163, 184, 0.1)' }
},
x: {
ticks: { color: '#94a3b8', maxRotation: 0 },
grid: { display: false }
}
}
}
});
// Get available cameras
async function getCameras() {
try {
await navigator.mediaDevices.getUserMedia({ video: true });
const allDevices = await navigator.mediaDevices.enumerateDevices();
devices = allDevices.filter(device => device.kind === 'videoinput');
cameraSelect.innerHTML = '<option value="">-- Auto Select --</option>';
devices.forEach((device, idx) => {
const option = document.createElement('option');
option.value = device.deviceId;
let label = device.label || `Camera ${idx + 1}`;
if (label.toLowerCase().includes('back') || label.toLowerCase().includes('rear')) {
label += ' 📷';
} else if (label.toLowerCase().includes('front')) {
label += ' 🤳';
}
option.text = label;
cameraSelect.appendChild(option);
});
} catch (err) {
console.error('Error accessing cameras:', err);
alert('Cannot access cameras. Please check permissions.');
}
}
// Draw overlays (grid + lines)
function drawOverlays() {
// Draw grid
if (showGrid) {
const w = canvas.width;
const h = canvas.height;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
ctx.lineWidth = 1;
ctx.setLineDash([5, 5]);
for (let i = 1; i <= 2; i++) {
ctx.beginPath();
ctx.moveTo(w * i / 3, 0);
ctx.lineTo(w * i / 3, h);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, h * i / 3);
ctx.lineTo(w, h * i / 3);
ctx.stroke();
}
ctx.strokeStyle = 'rgba(0, 255, 0, 0.4)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(w / 2, 0);
ctx.lineTo(w / 2, h);
ctx.stroke();
ctx.beginPath();
ctx.moveTo(0, h / 2);
ctx.lineTo(w, h / 2);
ctx.stroke();
ctx.setLineDash([]);
}
// Draw current line
if (currentLine) {
const w = canvas.width;
const h = canvas.height;
const x1 = currentLine[0] * w;
const y1 = currentLine[1] * h;
const x2 = currentLine[2] * w;
const y2 = currentLine[3] * h;
ctx.strokeStyle = '#00FF00';
ctx.lineWidth = 5;
ctx.setLineDash([]);
ctx.shadowColor = '#00FF00';
ctx.shadowBlur = 10;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
ctx.shadowBlur = 0;
ctx.fillStyle = '#00FF00';
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
[{ x: x1, y: y1 }, { x: x2, y: y2 }].forEach(point => {
ctx.beginPath();
ctx.arc(point.x, point.y, 10, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
});
ctx.font = 'bold 18px Arial';
const labelText = '⚡ COUNT LINE';
const labelX = (x1 + x2) / 2;
const labelY = (y1 + y2) / 2 - 15;
const textWidth = ctx.measureText(labelText).width;
ctx.fillStyle = 'rgba(0, 255, 0, 0.8)';
ctx.fillRect(labelX - textWidth/2 - 10, labelY - 25, textWidth + 20, 35);
ctx.fillStyle = '#000000';
ctx.fillText(labelText, labelX - textWidth/2, labelY);
}
// Draw temp line
if (isDrawingMode && tempLine) {
ctx.strokeStyle = '#FF0000';
ctx.lineWidth = 3;
ctx.setLineDash([10, 5]);
ctx.beginPath();
ctx.moveTo(tempLine.x1, tempLine.y1);
ctx.lineTo(tempLine.x2, tempLine.y2);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#FF0000';
ctx.strokeStyle = '#FFFFFF';
ctx.lineWidth = 2;
[{ x: tempLine.x1, y: tempLine.y1 }, { x: tempLine.x2, y: tempLine.y2 }].forEach(point => {
ctx.beginPath();
ctx.arc(point.x, point.y, 8, 0, 2 * Math.PI);
ctx.fill();
ctx.stroke();
});
}
}
// WebSocket connection
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}/video_feed`);
ws.onopen = () => {
console.log('WebSocket connected');
statusIndicator.textContent = 'Live';
statusIndicator.className = 'text-xs text-emerald-500 animate-pulse font-normal';
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type === 'frame') {
const img = new Image();
img.onload = () => {
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
drawOverlays();
};
img.src = msg.data;
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
statusIndicator.textContent = 'Error';
statusIndicator.className = 'text-xs text-red-500 font-normal';
};
ws.onclose = () => {
console.log('WebSocket closed');
statusIndicator.textContent = 'Ready';
statusIndicator.className = 'text-xs text-slate-400 font-normal';
};
}
// Start camera
async function startCamera() {
const deviceId = cameraSelect.value;
try {
const constraints = {
video: {
width: { ideal: 1280 },
height: { ideal: 720 },
facingMode: deviceId ? undefined : 'environment',
deviceId: deviceId ? { exact: deviceId } : undefined
}
};
stream = await navigator.mediaDevices.getUserMedia(constraints);
video.srcObject = stream;
await video.play();
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
cameraPlaceholder.classList.add('hidden');
liveIndicator.classList.remove('hidden');
connectWebSocket();
sendFrames();
startBtn.disabled = true;
stopBtn.disabled = false;
drawLineBtn.disabled = false;
toggleGridBtn.disabled = false;
cameraSelect.disabled = true;
hintText.textContent = 'Click "Show Grid" for reference, then "Draw Line" to set counting line.';
hintText.className = 'w-full text-center text-sm p-3 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded-lg border border-emerald-200 dark:border-emerald-800';
} catch (err) {
console.error('Camera error:', err);
alert('Cannot access camera. Please check permissions.');
}
}
// Send frames to server
function sendFrames() {
if (!ws || ws.readyState !== WebSocket.OPEN) {
animationId = requestAnimationFrame(sendFrames);
return;
}
if (!isDrawingMode) {
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
const frameData = canvas.toDataURL('image/jpeg', 0.8);
ws.send(JSON.stringify({
type: 'frame',
data: frameData
}));
}
animationId = requestAnimationFrame(sendFrames);
}
// Stop camera
function stopCamera() {
if (stream) {
stream.getTracks().forEach(track => track.stop());
stream = null;
}
if (ws) {
ws.close();
ws = null;
}
if (animationId) {
cancelAnimationFrame(animationId);
animationId = null;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
cameraPlaceholder.classList.remove('hidden');
liveIndicator.classList.add('hidden');
startBtn.disabled = false;
stopBtn.disabled = true;
drawLineBtn.disabled = true;
toggleGridBtn.disabled = true;
cameraSelect.disabled = false;
statusIndicator.textContent = 'Ready';
statusIndicator.className = 'text-xs text-slate-400 font-normal';
hintText.textContent = 'Click "Start Camera" to begin.';
hintText.className = 'w-full text-center text-sm p-3 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg border border-blue-200 dark:border-blue-800';
showGrid = false;
currentLine = null;
lineInfo.textContent = 'No lines drawn';
}
// Toggle grid
function toggleGrid() {
showGrid = !showGrid;
if (showGrid) {
toggleGridBtn.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Hide Grid
`;
toggleGridBtn.className = 'flex items-center gap-2 px-4 py-2 rounded-full text-sm font-bold bg-emerald-500 text-white shadow-md transition-all';
hintText.textContent = '✅ Grid enabled! Use as reference for accurate line placement.';
} else {
toggleGridBtn.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM14 5a11 1 0 011-1h4a1 1 0 011 1v7a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1H5a1 1 0 01-1-1v-3zM14 16a1 1 0 011-1h4a1 1 0 011 1v3a1 1 0 01-1 1h-4a1 1 0 01-1-1v-3z"></path>
</svg>
Show Grid
`;
toggleGridBtn.className = 'flex items-center gap-2 px-4 py-2 rounded-full text-sm font-bold bg-white dark:bg-slate-600 text-slate-600 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-500 transition-all';
hintText.textContent = 'Click "Draw Line" to set counting line.';
}
}
// Toggle draw line mode
function toggleDrawLine() {
isDrawingMode = !isDrawingMode;
if (isDrawingMode) {
canvas.classList.add('drawing');
drawLineBtn.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
Cancel
`;
drawLineBtn.className = 'flex items-center gap-2 px-4 py-2 rounded-full text-sm font-bold bg-amber-500 text-white shadow-md animate-pulse transition-all';
drawingIndicator.classList.remove('hidden');
hintText.textContent = '📍 Click 2 points on video to draw counting line. Use grid as reference.';
hintText.className = 'w-full text-center text-sm p-3 bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-lg border border-amber-200 dark:border-amber-800';
linePoints = [];
tempLine = null;
} else {
canvas.classList.remove('drawing');
drawLineBtn.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
Draw Line
`;
drawLineBtn.className = 'flex items-center gap-2 px-4 py-2 rounded-full text-sm font-bold bg-white dark:bg-slate-600 text-slate-600 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-500 transition-all';
drawingIndicator.classList.add('hidden');
hintText.textContent = 'Click "Draw Line" to set a new counting line.';
hintText.className = 'w-full text-center text-sm p-3 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg border border-blue-200 dark:border-blue-800';
linePoints = [];
tempLine = null;
}
}
// Canvas click handler
canvas.addEventListener('click', (e) => {
if (!isDrawingMode) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
linePoints.push({ x, y });
if (linePoints.length === 1) {
hintText.textContent = '📍 Click second point to complete counting line.';
tempLine = { x1: x, y1: y, x2: x, y2: y };
}
if (linePoints.length === 2) {
const line = [
linePoints[0].x / canvas.width,
linePoints[0].y / canvas.height,
linePoints[1].x / canvas.width,
linePoints[1].y / canvas.height
];
currentLine = line;
lineInfo.textContent = '1 line drawn';
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'set_line',
line: line
}));
}
console.log('Line created:', line);
isDrawingMode = false;
canvas.classList.remove('drawing');
drawLineBtn.innerHTML = `
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
</svg>
Draw Line
`;
drawLineBtn.className = 'flex items-center gap-2 px-4 py-2 rounded-full text-sm font-bold bg-white dark:bg-slate-600 text-slate-600 dark:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-500 transition-all';
drawingIndicator.classList.add('hidden');
hintText.textContent = '✅ Counting line created! Objects crossing this line will be counted.';
hintText.className = 'w-full text-center text-sm p-3 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded-lg border border-emerald-200 dark:border-emerald-800';
linePoints = [];
tempLine = null;
}
});
// Canvas mousemove handler
canvas.addEventListener('mousemove', (e) => {
if (!isDrawingMode || linePoints.length !== 1) return;
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const x = (e.clientX - rect.left) * scaleX;
const y = (e.clientY - rect.top) * scaleY;
tempLine = {
x1: linePoints[0].x,
y1: linePoints[0].y,
x2: x,
y2: y
};
});
// Touch support
canvas.addEventListener('touchstart', (e) => {
e.preventDefault();
const touch = e.touches[0];
const mouseEvent = new MouseEvent('click', {
clientX: touch.clientX,
clientY: touch.clientY
});
canvas.dispatchEvent(mouseEvent);
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
const mouseEvent = new MouseEvent('mousemove', {
clientX: touch.clientX,
clientY: touch.clientY
});
canvas.dispatchEvent(mouseEvent);
});
// Clear line
async function clearLine() {
if (!currentLine && !confirm('No line to clear. Reset count anyway?')) return;
currentLine = null;
lineInfo.textContent = 'No lines drawn';
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'set_line',
line: []
}));
}
hintText.textContent = 'Line cleared. Click "Draw Line" to set a new counting line.';
hintText.className = 'w-full text-center text-sm p-3 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg border border-blue-200 dark:border-blue-800';
}
// Reset count
async function resetCount() {
if (confirm('Reset count to 0?')) {
try {
await fetch('/count', { method: 'DELETE' });
document.getElementById('totalCount').textContent = '0';
// Clear chart
countChart.data.labels = [];
countChart.data.datasets[0].data = [];
countChart.update();
hintText.textContent = '✅ Count reset to 0!';
hintText.className = 'w-full text-center text-sm p-3 bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300 rounded-lg border border-emerald-200 dark:border-emerald-800';
setTimeout(() => {
hintText.textContent = 'Objects crossing the line will be counted.';
hintText.className = 'w-full text-center text-sm p-3 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded-lg border border-blue-200 dark:border-blue-800';
}, 3000);
} catch (error) {
console.error('Error resetting count:', error);
alert('Failed to reset count. Please try again.');
}
}
}
// Update chart with new count
function updateChart(count) {
const now = new Date();
const timeLabel = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
countChart.data.labels.push(timeLabel);
countChart.data.datasets[0].data.push(count);
// Keep only last 20 data points
if (countChart.data.labels.length > 20) {
countChart.data.labels.shift();
countChart.data.datasets[0].data.shift();
}
countChart.update('none'); // Update without animation for smoother performance
}
// Update stats
async function updateStats() {
try {
const response = await fetch('/count');
const data = await response.json();
document.getElementById('totalCount').textContent = data.total || 0;
document.getElementById('fpsValue').textContent = data.fps || 0;
if (data.total !== undefined) {
updateChart(data.total);
}
} catch (error) {
console.error('Error fetching stats:', error);
}
}
// Event listeners
startBtn.addEventListener('click', startCamera);
stopBtn.addEventListener('click', stopCamera);
drawLineBtn.addEventListener('click', toggleDrawLine);
toggleGridBtn.addEventListener('click', toggleGrid);
resetCountBtn.addEventListener('click', resetCount);
clearLineBtn.addEventListener('click', clearLine);
// Initialize
getCameras();
// Update stats every second
setInterval(updateStats, 1000);
// Dark mode detection
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.classList.add('dark');
}
</script>
</body>
</html>