NV9523 commited on
Commit
9a61652
·
verified ·
1 Parent(s): 81ce609

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ẽ

Browse files
Files changed (1) hide show
  1. index.html +678 -642
index.html CHANGED
@@ -1,218 +1,327 @@
1
  <!DOCTYPE html>
2
- <html lang="vi">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>YOLO Detection - Browser Camera</title>
7
- <link rel="stylesheet" href="style.css">
8
- <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
9
- <style>
10
- .container {
11
- background: white;
12
- border-radius: 16px;
13
- box-shadow: 0 20px 60px rgba(0,0,0,0.3);
14
- overflow: hidden;
15
- max-width: 1200px;
16
- width: 100%;
17
- }
18
- .header {
19
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
20
- color: white;
21
- padding: 24px;
22
- text-align: center;
23
- }
24
- h1 {
25
- font-size: 28px;
26
- font-weight: 700;
27
- margin-bottom: 8px;
28
- }
29
- .status {
30
- font-size: 14px;
31
- opacity: 0.9;
32
- }
33
- .content {
34
- padding: 24px;
35
- }
36
- .video-container {
37
- position: relative;
38
- background: #000;
39
- border-radius: 12px;
40
- overflow: hidden;
41
- margin-bottom: 24px;
42
- }
43
- #outputCanvas {
44
- width: 100%;
45
- height: auto;
46
- display: block;
47
- cursor: crosshair;
48
- }
49
- #outputCanvas.drawing {
50
- cursor: crosshair;
51
- }
52
- #videoInput {
53
- display: none;
54
- }
55
- .controls {
56
- text-align: center;
57
- margin-bottom: 24px;
58
- display: flex;
59
- gap: 12px;
60
- justify-content: center;
61
- flex-wrap: wrap;
62
- }
63
- button {
64
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
65
- color: white;
66
- border: none;
67
- padding: 12px 32px;
68
- border-radius: 8px;
69
- font-size: 16px;
70
- font-weight: 600;
71
- cursor: pointer;
72
- transition: transform 0.2s;
73
- }
74
- button:hover {
75
- transform: translateY(-2px);
76
- }
77
- button:disabled {
78
- opacity: 0.5;
79
- cursor: not-allowed;
80
- }
81
- button.draw-mode {
82
- background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
83
- }
84
- button.grid-mode {
85
- background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
86
- }
87
- .hint {
88
- text-align: center;
89
- color: #666;
90
- font-size: 14px;
91
- margin-bottom: 16px;
92
- padding: 8px;
93
- background: #f0f0f0;
94
- border-radius: 8px;
95
- }
96
- .hint.active {
97
- background: #fff3cd;
98
- color: #856404;
99
- }
100
- .stats {
101
- display: grid;
102
- grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
103
- gap: 16px;
104
- }
105
- .stat-card {
106
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
107
- color: white;
108
- padding: 20px;
109
- border-radius: 12px;
110
- text-align: center;
111
- }
112
- .stat-label {
113
- font-size: 14px;
114
- opacity: 0.9;
115
- margin-bottom: 8px;
116
- }
117
- .stat-value {
118
- font-size: 32px;
119
- font-weight: 700;
120
- }
121
- .footer {
122
- text-align: center;
123
- padding: 16px;
124
- color: #666;
125
- font-size: 14px;
126
- }
127
- @media (max-width: 768px) {
128
- h1 { font-size: 24px; }
129
- .stat-value { font-size: 28px; }
130
  }
131
- </style>
132
- </head>
133
- <body>
134
- <div class="container">
135
- <div class="header">
136
- <h1>📹 YOLO Detection - Browser Camera</h1>
137
- <div class="status">Real-time Object Detection from Your Camera</div>
138
- </div>
139
- <div class="content">
140
- <div class="controls">
141
- <select id="cameraSelect" class="camera-select">
142
- <option value="">-- Auto Select --</option>
143
- </select>
144
- <button id="startBtn">🎥 Start Camera</button>
145
- <button id="stopBtn" disabled>⏹️ Stop Camera</button>
146
- <button id="drawLineBtn" disabled>✏️ Draw Line</button>
147
- <button id="toggleGridBtn" disabled>📐 Show Grid</button>
148
- <button id="resetCountBtn">🔄 Reset Count</button>
149
- </div>
150
- <div class="hint" id="hintText">
151
- Click "Start Camera" để bắt đầu. Sau đó nhấn "Draw Line" để vẽ vạch đếm.
152
- </div>
153
-
154
- <div class="video-container">
155
- <video id="videoInput" autoplay playsinline></video>
156
- <canvas id="outputCanvas"></canvas>
157
- </div>
158
- <div class="stats">
159
- <div class="stat-card">
160
- <div class="stat-label">Total Objects</div>
161
- <div class="stat-value" id="totalCount">0</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  </div>
163
- <div class="stat-card">
164
- <div class="stat-label">FPS</div>
165
- <div class="stat-value" id="fpsValue">0</div>
 
166
  </div>
167
- <div class="stat-card">
168
- <div class="stat-label">Status</div>
169
- <div class="stat-value" style="font-size: 20px;" id="statusText">⚪ Ready</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
170
  </div>
171
  </div>
172
-
173
- <div class="chart-container">
174
- <canvas id="countChart"></canvas>
175
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
176
  </div>
177
  </div>
178
-
179
- <div class="footer">
180
- Powered by FastAPI + YOLO | Browser Camera Detection
181
- </div>
182
  </div>
 
183
 
184
- <script>
185
- const video = document.getElementById('videoInput');
186
- const canvas = document.getElementById('outputCanvas');
187
- const ctx = canvas.getContext('2d');
188
- const startBtn = document.getElementById('startBtn');
189
- const stopBtn = document.getElementById('stopBtn');
190
- const drawLineBtn = document.getElementById('drawLineBtn');
191
- const toggleGridBtn = document.getElementById('toggleGridBtn');
192
- const resetCountBtn = document.getElementById('resetCountBtn');
193
- const statusText = document.getElementById('statusText');
194
- const hintText = document.getElementById('hintText');
195
-
196
- let ws = null;
197
- let stream = null;
198
- let animationId = null;
199
- let isDrawingMode = false;
200
- let showGrid = false;
201
- let linePoints = [];
202
- let tempLine = null;
203
- let currentLine = null; // Lưu line đã vẽ
204
-
205
- // Vẽ tất cả overlays (grid + lines)
206
- function drawOverlays() {
207
- drawGrid();
208
- drawCountLine();
209
- drawTempLine();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  }
211
-
212
- // Vẽ line đã vẽ (COUNT LINE)
213
- function drawCountLine() {
214
- if (!currentLine) return;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  const w = canvas.width;
217
  const h = canvas.height;
218
  const x1 = currentLine[0] * w;
@@ -220,8 +329,7 @@ Powered by FastAPI + YOLO | Browser Camera Detection
220
  const x2 = currentLine[2] * w;
221
  const y2 = currentLine[3] * h;
222
 
223
- // Vẽ line chính màu xanh dương đậm
224
- ctx.strokeStyle = '#00FF00'; // Màu xanh lá neon
225
  ctx.lineWidth = 5;
226
  ctx.setLineDash([]);
227
  ctx.shadowColor = '#00FF00';
@@ -232,39 +340,30 @@ Powered by FastAPI + YOLO | Browser Camera Detection
232
  ctx.stroke();
233
  ctx.shadowBlur = 0;
234
 
235
- // Vẽ điểm endpoint
236
  ctx.fillStyle = '#00FF00';
237
  ctx.strokeStyle = '#FFFFFF';
238
  ctx.lineWidth = 2;
239
- ctx.beginPath();
240
- ctx.arc(x1, y1, 10, 0, 2 * Math.PI);
241
- ctx.fill();
242
- ctx.stroke();
243
- ctx.beginPath();
244
- ctx.arc(x2, y2, 10, 0, 2 * Math.PI);
245
- ctx.fill();
246
- ctx.stroke();
247
 
248
- // Label với background
249
  ctx.font = 'bold 18px Arial';
250
  const labelText = '⚡ COUNT LINE';
251
  const labelX = (x1 + x2) / 2;
252
  const labelY = (y1 + y2) / 2 - 15;
253
  const textWidth = ctx.measureText(labelText).width;
254
 
255
- // Background
256
  ctx.fillStyle = 'rgba(0, 255, 0, 0.8)';
257
  ctx.fillRect(labelX - textWidth/2 - 10, labelY - 25, textWidth + 20, 35);
258
-
259
- // Text
260
  ctx.fillStyle = '#000000';
261
  ctx.fillText(labelText, labelX - textWidth/2, labelY);
262
  }
263
 
264
- // Vẽ line tạm (preview khi đang vẽ)
265
- function drawTempLine() {
266
- if (!isDrawingMode || !tempLine) return;
267
-
268
  ctx.strokeStyle = '#FF0000';
269
  ctx.lineWidth = 3;
270
  ctx.setLineDash([10, 5]);
@@ -274,465 +373,402 @@ Powered by FastAPI + YOLO | Browser Camera Detection
274
  ctx.stroke();
275
  ctx.setLineDash([]);
276
 
277
- // Vẽ điểm
278
  ctx.fillStyle = '#FF0000';
279
  ctx.strokeStyle = '#FFFFFF';
280
  ctx.lineWidth = 2;
281
- ctx.beginPath();
282
- ctx.arc(tempLine.x1, tempLine.y1, 8, 0, 2 * Math.PI);
283
- ctx.fill();
284
- ctx.stroke();
285
-
286
- if (tempLine.x2 !== undefined) {
287
  ctx.beginPath();
288
- ctx.arc(tempLine.x2, tempLine.y2, 8, 0, 2 * Math.PI);
289
  ctx.fill();
290
  ctx.stroke();
291
- }
292
-
293
- // Hiển thị tọa độ
294
- ctx.font = 'bold 14px Arial';
295
- const coord1 = `(${Math.round(tempLine.x1)}, ${Math.round(tempLine.y1)})`;
296
-
297
- // Background cho text
298
- const textWidth1 = ctx.measureText(coord1).width;
299
- ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
300
- ctx.fillRect(tempLine.x1 + 5, tempLine.y1 - 25, textWidth1 + 10, 20);
301
-
302
- ctx.fillStyle = '#FFFFFF';
303
- ctx.fillText(coord1, tempLine.x1 + 10, tempLine.y1 - 10);
304
-
305
- if (tempLine.x2 !== undefined) {
306
- const coord2 = `(${Math.round(tempLine.x2)}, ${Math.round(tempLine.y2)})`;
307
- const textWidth2 = ctx.measureText(coord2).width;
308
-
309
- ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
310
- ctx.fillRect(tempLine.x2 + 5, tempLine.y2 - 25, textWidth2 + 10, 20);
311
-
312
- ctx.fillStyle = '#FFFFFF';
313
- ctx.fillText(coord2, tempLine.x2 + 10, tempLine.y2 - 10);
314
- }
315
  }
 
 
 
 
 
 
316
 
317
- // Vẽ grid lines
318
- function drawGrid() {
319
- if (!showGrid) return;
320
-
321
- const w = canvas.width;
322
- const h = canvas.height;
323
-
324
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
325
- ctx.lineWidth = 1;
326
- ctx.setLineDash([5, 5]);
327
-
328
- // Vẽ 9 ô (3x3 grid)
329
- for (let i = 1; i <= 2; i++) {
330
- // Vertical lines
331
- ctx.beginPath();
332
- ctx.moveTo(w * i / 3, 0);
333
- ctx.lineTo(w * i / 3, h);
334
- ctx.stroke();
335
-
336
- // Horizontal lines
337
- ctx.beginPath();
338
- ctx.moveTo(0, h * i / 3);
339
- ctx.lineTo(w, h * i / 3);
340
- ctx.stroke();
341
- }
342
-
343
- // Center cross (highlight)
344
- ctx.strokeStyle = 'rgba(0, 255, 0, 0.4)';
345
- ctx.lineWidth = 2;
346
-
347
- // Center vertical
348
- ctx.beginPath();
349
- ctx.moveTo(w / 2, 0);
350
- ctx.lineTo(w / 2, h);
351
- ctx.stroke();
352
-
353
- // Center horizontal
354
- ctx.beginPath();
355
- ctx.moveTo(0, h / 2);
356
- ctx.lineTo(w, h / 2);
357
- ctx.stroke();
358
-
359
- // Reset
360
- ctx.setLineDash([]);
361
- }
362
- // Chart setup
363
- const countChart = new Chart(
364
- document.getElementById('countChart'),
365
- {
366
- type: 'line',
367
- data: {
368
- labels: [],
369
- datasets: [{
370
- label: 'Objects Count',
371
- data: [],
372
- borderColor: '#667eea',
373
- backgroundColor: 'rgba(102, 126, 234, 0.1)',
374
- tension: 0.1,
375
- fill: true
376
- }]
377
- },
378
- options: {
379
- responsive: true,
380
- scales: {
381
- y: {
382
- beginAtZero: true
383
- }
384
- }
385
- }
386
  }
387
- );
 
 
 
 
 
 
 
 
 
 
 
 
 
388
 
389
- // WebSocket connection
390
- function connectWebSocket() {
391
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
392
- ws = new WebSocket(`${protocol}//${window.location.host}/video_feed`);
393
-
394
- ws.onopen = () => {
395
- console.log('WebSocket connected');
396
- statusText.innerHTML = '🟢 Active';
397
- };
398
-
399
- ws.onmessage = (event) => {
400
- const msg = JSON.parse(event.data);
401
- if (msg.type === 'frame') {
402
- const img = new Image();
403
- img.onload = () => {
404
- canvas.width = img.width;
405
- canvas.height = img.height;
406
-
407
- // Vẽ frame từ server
408
- ctx.drawImage(img, 0, 0);
409
-
410
- // Vẽ overlays (grid + lines) lên trên frame
411
- drawOverlays();
412
- };
413
- img.src = msg.data;
414
- } else if (msg.type === 'count_update') {
415
- updateChart(msg.count);
416
  }
417
  };
418
- ws.onerror = (error) => {
419
- console.error('WebSocket error:', error);
420
- statusText.innerHTML = '🔴 Error';
421
- };
422
 
423
- ws.onclose = () => {
424
- console.log('WebSocket closed');
425
- statusText.innerHTML = '⚪ Ready';
426
- };
427
- }
428
- // Update chart with new count
429
- function updateChart(count) {
430
- const now = new Date();
431
- const timeLabel = `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`;
432
 
433
- countChart.data.labels.push(timeLabel);
434
- countChart.data.datasets[0].data.push(count);
435
 
436
- // Keep only last 20 data points
437
- if (countChart.data.labels.length > 20) {
438
- countChart.data.labels.shift();
439
- countChart.data.datasets[0].data.shift();
440
- }
441
 
442
- countChart.update();
443
- }
444
- // Get available cameras
445
- async function getCameras() {
446
- try {
447
- // Yêu cầu quyền truy cập camera trước khi liệt kê
448
- await navigator.mediaDevices.getUserMedia({ video: true });
449
-
450
- const devices = await navigator.mediaDevices.enumerateDevices();
451
- const videoDevices = devices.filter(device => device.kind === 'videoinput');
452
-
453
- const cameraSelect = document.getElementById('cameraSelect');
454
- cameraSelect.innerHTML = '<option value="">-- Auto Select --</option>';
455
-
456
- videoDevices.forEach((device, index) => {
457
- const option = document.createElement('option');
458
- option.value = device.deviceId;
459
- // Hiển thị tên camera thân thiện hơn
460
- let label = device.label || `Camera ${index + 1}`;
461
- if (label.includes('back') || label.toLowerCase().includes('rear')) {
462
- label += ' (Rear)';
463
- } else if (label.includes('front')) {
464
- label += ' (Front)';
465
- }
466
- option.text = label;
467
- cameraSelect.appendChild(option);
468
- });
469
- } catch (err) {
470
- console.error('Error accessing cameras:', err);
471
- statusText.innerHTML = '🔴 Camera Error';
472
- hintText.innerHTML = `
473
- <strong>⚠️ Camera Enumeration Failed</strong><br>
474
- Could not list available cameras. Please:<br>
475
- 1. Check camera permissions<br>
476
- 2. Ensure camera is connected<br>
477
- <button id="retryEnumBtn" style="margin-top: 10px; padding: 8px 16px; background: #f5576c; color: white; border: none; border-radius: 4px;">Retry</button>
478
- `;
479
- hintText.classList.add('active');
480
-
481
- document.getElementById('retryEnumBtn')?.addEventListener('click', () => {
482
- hintText.textContent = 'Reloading camera list...';
483
- getCameras();
484
- });
485
- }
486
- }
487
- // Start camera
488
- async function startCamera() {
489
- const cameraSelect = document.getElementById('cameraSelect');
490
- const deviceId = cameraSelect.value;
491
 
492
- try {
493
- const constraints = {
494
- video: {
495
- width: { ideal: 1280 },
496
- height: { ideal: 720 },
497
- facingMode: 'environment' // Ưu tiên camera sau (mobile)
498
- }
499
- };
500
-
501
- // Nếu có chọn camera cụ thể
502
- if (deviceId) {
503
- constraints.video.deviceId = { exact: deviceId };
504
- }
505
 
506
- stream = await navigator.mediaDevices.getUserMedia(constraints);
507
- video.srcObject = stream;
508
-
509
- await video.play();
510
- canvas.width = video.videoWidth;
511
- canvas.height = video.videoHeight;
512
-
513
- connectWebSocket();
514
- sendFrames();
515
-
516
- startBtn.disabled = true;
517
- stopBtn.disabled = false;
518
- drawLineBtn.disabled = false;
519
- toggleGridBtn.disabled = false;
520
- hintText.textContent = 'Nhấn "Show Grid" để hiển thị lưới, sau đó "Draw Line" để vẽ vạch đếm.';
521
- } catch (err) {
522
- console.error('Camera error:', err);
523
- statusText.innerHTML = '🔴 Camera Error';
524
- hintText.innerHTML = `
525
- <strong>⚠️ Camera Access Denied</strong><br>
526
- Please check:<br>
527
- 1. Camera permissions in browser settings<br>
528
- 2. No other apps using camera<br>
529
- 3. Physical camera connection<br>
530
- <button id="retryBtn" style="margin-top: 10px; padding: 8px 16px; background: #f5576c; color: white; border: none; border-radius: 4px;">Retry</button>
531
- `;
532
- hintText.classList.add('active');
533
- startBtn.disabled = false;
534
- stopBtn.disabled = true;
535
-
536
- // Add retry button functionality
537
- document.getElementById('retryBtn')?.addEventListener('click', () => {
538
- hintText.textContent = 'Attempting to access camera...';
539
- startCamera();
540
- });
541
- }
542
- }
543
-
544
- // Send frames to server
545
- function sendFrames() {
546
- if (!ws || ws.readyState !== WebSocket.OPEN) {
547
- animationId = requestAnimationFrame(sendFrames);
548
- return;
549
- }
550
-
551
- // Không gửi frame khi đang vẽ line để tránh lag
552
- if (!isDrawingMode) {
553
- ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
554
- const frameData = canvas.toDataURL('image/jpeg', 0.8);
555
-
556
- ws.send(JSON.stringify({
557
- type: 'frame',
558
- data: frameData
559
- }));
560
- }
561
-
562
  animationId = requestAnimationFrame(sendFrames);
 
563
  }
564
 
565
- // Stop camera
566
- function stopCamera() {
567
- if (stream) {
568
- stream.getTracks().forEach(track => track.stop());
569
- stream = null;
570
- }
571
- if (ws) {
572
- ws.close();
573
- ws = null;
574
- }
575
- if (animationId) {
576
- cancelAnimationFrame(animationId);
577
- animationId = null;
578
- }
579
 
580
- startBtn.disabled = false;
581
- stopBtn.disabled = true;
582
- drawLineBtn.disabled = true;
583
- toggleGridBtn.disabled = true;
584
- statusText.innerHTML = '⚪ Ready';
585
- hintText.textContent = 'Click "Start Camera" để bắt đầu.';
586
- showGrid = false;
587
- currentLine = null; // Clear line khi stop
588
  }
589
 
590
- // Toggle grid
591
- function toggleGrid() {
592
- showGrid = !showGrid;
593
-
594
- if (showGrid) {
595
- toggleGridBtn.classList.add('grid-mode');
596
- toggleGridBtn.textContent = '📐 Hide Grid';
597
- hintText.textContent = '✅ Lưới đã bật! Dùng làm tham chiếu để vẽ line chính xác.';
598
- } else {
599
- toggleGridBtn.classList.remove('grid-mode');
600
- toggleGridBtn.textContent = '📐 Show Grid';
601
- hintText.textContent = 'Nhấn "Draw Line" để vẽ vạch đếm vật thể.';
602
- }
 
 
 
603
  }
604
 
605
- // Draw line mode
606
- function toggleDrawLine() {
607
- isDrawingMode = !isDrawingMode;
608
-
609
- if (isDrawingMode) {
610
- canvas.classList.add('drawing');
611
- drawLineBtn.classList.add('draw-mode');
612
- drawLineBtn.textContent = '❌ Cancel Draw';
613
- hintText.textContent = '📍 Click 2 điểm trên video để vẽ vạch đếm. Dùng lưới làm tham chiếu.';
614
- hintText.classList.add('active');
615
- linePoints = [];
616
- tempLine = null;
617
- } else {
618
- canvas.classList.remove('drawing');
619
- drawLineBtn.classList.remove('draw-mode');
620
- drawLineBtn.textContent = '✏️ Draw Line';
621
- hintText.textContent = 'Nhấn "Draw Line" để vẽ vạch đếm vật thể.';
622
- hintText.classList.remove('active');
623
- linePoints = [];
624
- tempLine = null;
625
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
626
  }
627
 
628
- // Canvas click để vẽ line
629
- canvas.addEventListener('click', (e) => {
630
- if (!isDrawingMode) return;
631
-
632
- const rect = canvas.getBoundingClientRect();
633
- const scaleX = canvas.width / rect.width;
634
- const scaleY = canvas.height / rect.height;
635
-
636
- const x = (e.clientX - rect.left) * scaleX;
637
- const y = (e.clientY - rect.top) * scaleY;
638
-
639
- linePoints.push({ x, y });
640
-
641
- if (linePoints.length === 1) {
642
- hintText.textContent = '📍 Click điểm thứ 2 để hoàn thành vạch đếm.';
643
- tempLine = { x1: x, y1: y };
644
  }
645
 
646
- if (linePoints.length === 2) {
647
- // Chuyển đổi sang tỷ lệ % (0-1)
648
- const line = [
649
- linePoints[0].x / canvas.width,
650
- linePoints[0].y / canvas.height,
651
- linePoints[1].x / canvas.width,
652
- linePoints[1].y / canvas.height
653
- ];
654
-
655
- // Lưu line để hiển thị
656
- currentLine = line;
657
-
658
- // Gửi line mới đến server
659
- if (ws && ws.readyState === WebSocket.OPEN) {
660
- ws.send(JSON.stringify({
661
- type: 'set_line',
662
- line: line
663
- }));
664
- }
665
-
666
- console.log('Line created:', line);
667
-
668
- // Tắt draw mode
669
- isDrawingMode = false;
670
- canvas.classList.remove('drawing');
671
- drawLineBtn.classList.remove('draw-mode');
672
- drawLineBtn.textContent = '✏️ Draw Line';
673
- hintText.textContent = '✅ Vạch đếm đã được tạo! Vật thể sẽ được đếm khi chạm vạch.';
674
- hintText.classList.remove('active');
675
- linePoints = [];
676
- tempLine = null;
677
- }
678
- });
679
 
680
- // Canvas mousemove để preview line
681
- canvas.addEventListener('mousemove', (e) => {
682
- if (!isDrawingMode || linePoints.length !== 1) return;
683
-
684
- const rect = canvas.getBoundingClientRect();
685
- const scaleX = canvas.width / rect.width;
686
- const scaleY = canvas.height / rect.height;
687
-
688
- const x = (e.clientX - rect.left) * scaleX;
689
- const y = (e.clientY - rect.top) * scaleY;
690
-
691
- tempLine = {
692
- x1: linePoints[0].x,
693
- y1: linePoints[0].y,
694
- x2: x,
695
- y2: y
696
- };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  });
 
 
 
 
 
 
698
 
699
- // Reset count
700
- async function resetCount() {
701
- if (confirm('Bạn có chắc muốn reset số lượng đếm về 0?')) {
702
- try {
703
- await fetch('/count', { method: 'DELETE' });
704
- updateStats();
705
- alert('✅ Đã reset count về 0!');
706
- } catch (error) {
707
- console.error('Error resetting count:', error);
708
- }
709
- }
710
  }
711
- // Update stats
712
- async function updateStats() {
 
 
 
 
 
 
713
  try {
714
- const response = await fetch('/count');
715
- const data = await response.json();
716
 
717
- document.getElementById('totalCount').textContent = data.total;
718
- document.getElementById('fpsValue').textContent = data.fps;
719
- updateChart(data.total);
 
 
 
 
 
 
 
 
 
720
  } catch (error) {
721
- console.error('Error fetching stats:', error);
 
722
  }
723
  }
724
- // Event listeners
725
- startBtn.addEventListener('click', startCamera);
726
- stopBtn.addEventListener('click', stopCamera);
727
- drawLineBtn.addEventListener('click', toggleDrawLine);
728
- toggleGridBtn.addEventListener('click', toggleGrid);
729
- resetCountBtn.addEventListener('click', resetCount);
 
 
 
730
 
731
- // Initialize
732
- getCameras();
 
 
 
733
 
734
- // Update stats every second
735
- setInterval(updateStats, 1000);
736
- </script>
737
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
738
  </html>
 
1
  <!DOCTYPE html>
2
+ <html lang="vi">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Camera Detection - YOLO</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
9
+ <style>
10
+ @keyframes fade-in {
11
+ from { opacity: 0; transform: translateY(10px); }
12
+ to { opacity: 1; transform: translateY(0); }
13
+ }
14
+ .animate-fade-in { animation: fade-in 0.5s ease-out; }
15
+ .animate-pulse-slow { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
16
+ #outputCanvas { cursor: default; }
17
+ #outputCanvas.drawing { cursor: crosshair; }
18
+
19
+ /* Dark mode styles */
20
+ @media (prefers-color-scheme: dark) {
21
+ body { background-color: #0f172a; }
22
+ }
23
+ </style>
24
+ <script>
25
+ tailwind.config = {
26
+ darkMode: 'class',
27
+ theme: {
28
+ extend: {
29
+ colors: {
30
+ 'rix': {
31
+ 50: '#eff6ff',
32
+ 500: '#3b82f6',
33
+ 600: '#2563eb',
34
+ 700: '#1d4ed8',
35
+ }
36
+ }
37
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  }
39
+ }
40
+ </script>
41
+ </head>
42
+ <body class="bg-slate-100 dark:bg-slate-900 min-h-screen p-4 md:p-8">
43
+ <div class="max-w-7xl mx-auto space-y-6 animate-fade-in pb-20">
44
+ <!-- Header -->
45
+ <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
46
+ <h1 class="text-3xl font-bold text-slate-800 dark:text-white border-l-4 border-rix-600 pl-4">
47
+ 📹 Camera Detection
48
+ </h1>
49
+ </div>
50
+
51
+ <!-- Main Container -->
52
+ <div class="bg-white dark:bg-slate-800 rounded-2xl shadow-lg border border-slate-100 dark:border-slate-700 p-6">
53
+ <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
54
+ <!-- Camera View Section -->
55
+ <div class="lg:col-span-2 flex flex-col items-center space-y-6">
56
+ <!-- Controls -->
57
+ <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">
58
+ <!-- Device Selector -->
59
+ <div class="flex items-center gap-2">
60
+ <svg class="w-5 h-5 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
61
+ <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>
62
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
63
+ </svg>
64
+ <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">
65
+ <option value="">-- Auto Select --</option>
66
+ </select>
67
+ </div>
68
+
69
+ <div class="w-px h-8 bg-slate-300 dark:bg-slate-600 mx-2 hidden sm:block"></div>
70
+
71
+ <!-- Play/Stop -->
72
+ <div class="flex items-center gap-2">
73
+ <button id="startBtn" class="bg-emerald-500 hover:bg-emerald-600 text-white p-2 rounded-full transition-colors shadow-sm">
74
+ <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
75
+ <path d="M8 5v14l11-7z"/>
76
+ </svg>
77
+ </button>
78
+ <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">
79
+ <svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
80
+ <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
81
+ </svg>
82
+ </button>
83
+ </div>
84
+
85
+ <div class="w-px h-8 bg-slate-300 dark:bg-slate-600 mx-2 hidden sm:block"></div>
86
+
87
+ <!-- Draw & Grid Controls -->
88
+ <div class="flex items-center gap-2">
89
+ <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">
90
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
91
+ <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>
92
+ </svg>
93
+ Show Grid
94
+ </button>
95
+
96
+ <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">
97
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
98
+ <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>
99
+ </svg>
100
+ Draw Line
101
+ </button>
102
+
103
+ <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">
104
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
105
+ <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>
106
+ </svg>
107
+ Clear
108
+ </button>
109
+
110
+ <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">
111
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
112
+ <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>
113
+ </svg>
114
+ Reset
115
+ </button>
116
+ </div>
117
  </div>
118
+
119
+ <!-- Hint Box -->
120
+ <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">
121
+ Click "Start Camera" to begin. Then "Show Grid" and "Draw Line" to set counting line.
122
  </div>
123
+
124
+ <!-- Video Viewport -->
125
+ <div class="relative w-full bg-black rounded-lg overflow-hidden shadow-2xl aspect-video group select-none">
126
+ <video id="videoInput" class="hidden" autoplay playsinline muted></video>
127
+ <canvas id="outputCanvas" class="w-full h-full object-cover"></canvas>
128
+
129
+ <!-- Live Indicator -->
130
+ <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">
131
+ <div class="w-2 h-2 bg-red-500 rounded-full animate-pulse"></div>
132
+ <span class="text-white text-xs font-bold tracking-wider">LIVE STREAM</span>
133
+ </div>
134
+
135
+ <!-- Drawing Mode Indicator -->
136
+ <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">
137
+ Drawing Mode Active
138
+ </div>
139
+
140
+ <!-- Placeholder -->
141
+ <div id="cameraPlaceholder" class="absolute inset-0 flex items-center justify-center bg-black/50 text-white/50">
142
+ <svg class="w-16 h-16" fill="none" stroke="currentColor" viewBox="0 0 24 24">
143
+ <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>
144
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
145
+ </svg>
146
+ </div>
147
  </div>
148
  </div>
149
+
150
+ <!-- Stats Section -->
151
+ <div class="lg:col-span-1">
152
+ <h3 class="font-bold text-slate-800 dark:text-white mb-2 flex justify-between items-center">
153
+ <span>Live Session Stats</span>
154
+ <span id="statusIndicator" class="text-xs text-slate-400 font-normal">Ready</span>
155
+ </h3>
156
+
157
+ <!-- Counter Card -->
158
+ <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">
159
+ <div class="text-xs text-slate-500 dark:text-slate-400 uppercase font-bold tracking-wider mb-1">Line Cross Count</div>
160
+ <div class="text-3xl font-bold text-rix-600 dark:text-white" id="totalCount">0</div>
161
+ <div class="mt-2 text-xs text-slate-400" id="lineInfo">No lines drawn</div>
162
+ </div>
163
+
164
+ <!-- FPS Card -->
165
+ <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">
166
+ <div class="text-xs text-slate-500 dark:text-slate-400 uppercase font-bold tracking-wider mb-1">FPS</div>
167
+ <div class="text-3xl font-bold text-emerald-600 dark:text-emerald-400" id="fpsValue">0</div>
168
+ </div>
169
+
170
+ <!-- Chart -->
171
+ <div class="bg-slate-50 dark:bg-slate-700/50 p-4 rounded-xl border border-slate-200 dark:border-slate-600">
172
+ <h4 class="text-sm font-bold text-slate-700 dark:text-slate-300 mb-4 flex items-center gap-2">
173
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
174
+ <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>
175
+ </svg>
176
+ Count History
177
+ </h4>
178
+ <div class="h-48 w-full">
179
+ <canvas id="countChart"></canvas>
180
+ </div>
181
+ </div>
182
+
183
+ <p class="text-xs text-slate-400 mt-4">
184
+ * Counts are cumulative for the current session.
185
+ </p>
186
  </div>
187
  </div>
 
 
 
 
188
  </div>
189
+ </div>
190
 
191
+ <script>
192
+ // Constants
193
+ const API_URL = window.location.origin;
194
+
195
+ // Elements
196
+ const video = document.getElementById('videoInput');
197
+ const canvas = document.getElementById('outputCanvas');
198
+ const ctx = canvas.getContext('2d');
199
+ const startBtn = document.getElementById('startBtn');
200
+ const stopBtn = document.getElementById('stopBtn');
201
+ const drawLineBtn = document.getElementById('drawLineBtn');
202
+ const toggleGridBtn = document.getElementById('toggleGridBtn');
203
+ const resetCountBtn = document.getElementById('resetCountBtn');
204
+ const clearLineBtn = document.getElementById('clearLineBtn');
205
+ const cameraSelect = document.getElementById('cameraSelect');
206
+ const hintText = document.getElementById('hintText');
207
+ const liveIndicator = document.getElementById('liveIndicator');
208
+ const drawingIndicator = document.getElementById('drawingIndicator');
209
+ const cameraPlaceholder = document.getElementById('cameraPlaceholder');
210
+ const statusIndicator = document.getElementById('statusIndicator');
211
+ const lineInfo = document.getElementById('lineInfo');
212
+
213
+ // State
214
+ let ws = null;
215
+ let stream = null;
216
+ let animationId = null;
217
+ let isDrawingMode = false;
218
+ let showGrid = false;
219
+ let linePoints = [];
220
+ let tempLine = null;
221
+ let currentLine = null;
222
+ let devices = [];
223
+
224
+ // Chart setup
225
+ const countChart = new Chart(document.getElementById('countChart'), {
226
+ type: 'line',
227
+ data: {
228
+ labels: [],
229
+ datasets: [{
230
+ label: 'Count',
231
+ data: [],
232
+ borderColor: '#3b82f6',
233
+ backgroundColor: 'rgba(59, 130, 246, 0.1)',
234
+ tension: 0.4,
235
+ fill: true,
236
+ pointRadius: 3,
237
+ pointHoverRadius: 5
238
+ }]
239
+ },
240
+ options: {
241
+ responsive: true,
242
+ maintainAspectRatio: false,
243
+ plugins: {
244
+ legend: { display: false }
245
+ },
246
+ scales: {
247
+ y: {
248
+ beginAtZero: true,
249
+ ticks: { color: '#94a3b8' },
250
+ grid: { color: 'rgba(148, 163, 184, 0.1)' }
251
+ },
252
+ x: {
253
+ ticks: { color: '#94a3b8', maxRotation: 0 },
254
+ grid: { display: false }
255
+ }
256
+ }
257
  }
258
+ });
259
+
260
+ // Get available cameras
261
+ async function getCameras() {
262
+ try {
263
+ await navigator.mediaDevices.getUserMedia({ video: true });
264
+ const allDevices = await navigator.mediaDevices.enumerateDevices();
265
+ devices = allDevices.filter(device => device.kind === 'videoinput');
266
+
267
+ cameraSelect.innerHTML = '<option value="">-- Auto Select --</option>';
268
+ devices.forEach((device, idx) => {
269
+ const option = document.createElement('option');
270
+ option.value = device.deviceId;
271
+ let label = device.label || `Camera ${idx + 1}`;
272
+ if (label.toLowerCase().includes('back') || label.toLowerCase().includes('rear')) {
273
+ label += ' 📷';
274
+ } else if (label.toLowerCase().includes('front')) {
275
+ label += ' 🤳';
276
+ }
277
+ option.text = label;
278
+ cameraSelect.appendChild(option);
279
+ });
280
+ } catch (err) {
281
+ console.error('Error accessing cameras:', err);
282
+ alert('Cannot access cameras. Please check permissions.');
283
+ }
284
+ }
285
+
286
+ // Draw overlays (grid + lines)
287
+ function drawOverlays() {
288
+ // Draw grid
289
+ if (showGrid) {
290
+ const w = canvas.width;
291
+ const h = canvas.height;
292
 
293
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
294
+ ctx.lineWidth = 1;
295
+ ctx.setLineDash([5, 5]);
296
+
297
+ for (let i = 1; i <= 2; i++) {
298
+ ctx.beginPath();
299
+ ctx.moveTo(w * i / 3, 0);
300
+ ctx.lineTo(w * i / 3, h);
301
+ ctx.stroke();
302
+
303
+ ctx.beginPath();
304
+ ctx.moveTo(0, h * i / 3);
305
+ ctx.lineTo(w, h * i / 3);
306
+ ctx.stroke();
307
+ }
308
+
309
+ ctx.strokeStyle = 'rgba(0, 255, 0, 0.4)';
310
+ ctx.lineWidth = 2;
311
+ ctx.beginPath();
312
+ ctx.moveTo(w / 2, 0);
313
+ ctx.lineTo(w / 2, h);
314
+ ctx.stroke();
315
+ ctx.beginPath();
316
+ ctx.moveTo(0, h / 2);
317
+ ctx.lineTo(w, h / 2);
318
+ ctx.stroke();
319
+
320
+ ctx.setLineDash([]);
321
+ }
322
+
323
+ // Draw current line
324
+ if (currentLine) {
325
  const w = canvas.width;
326
  const h = canvas.height;
327
  const x1 = currentLine[0] * w;
 
329
  const x2 = currentLine[2] * w;
330
  const y2 = currentLine[3] * h;
331
 
332
+ ctx.strokeStyle = '#00FF00';
 
333
  ctx.lineWidth = 5;
334
  ctx.setLineDash([]);
335
  ctx.shadowColor = '#00FF00';
 
340
  ctx.stroke();
341
  ctx.shadowBlur = 0;
342
 
 
343
  ctx.fillStyle = '#00FF00';
344
  ctx.strokeStyle = '#FFFFFF';
345
  ctx.lineWidth = 2;
346
+ [{ x: x1, y: y1 }, { x: x2, y: y2 }].forEach(point => {
347
+ ctx.beginPath();
348
+ ctx.arc(point.x, point.y, 10, 0, 2 * Math.PI);
349
+ ctx.fill();
350
+ ctx.stroke();
351
+ });
 
 
352
 
 
353
  ctx.font = 'bold 18px Arial';
354
  const labelText = '⚡ COUNT LINE';
355
  const labelX = (x1 + x2) / 2;
356
  const labelY = (y1 + y2) / 2 - 15;
357
  const textWidth = ctx.measureText(labelText).width;
358
 
 
359
  ctx.fillStyle = 'rgba(0, 255, 0, 0.8)';
360
  ctx.fillRect(labelX - textWidth/2 - 10, labelY - 25, textWidth + 20, 35);
 
 
361
  ctx.fillStyle = '#000000';
362
  ctx.fillText(labelText, labelX - textWidth/2, labelY);
363
  }
364
 
365
+ // Draw temp line
366
+ if (isDrawingMode && tempLine) {
 
 
367
  ctx.strokeStyle = '#FF0000';
368
  ctx.lineWidth = 3;
369
  ctx.setLineDash([10, 5]);
 
373
  ctx.stroke();
374
  ctx.setLineDash([]);
375
 
 
376
  ctx.fillStyle = '#FF0000';
377
  ctx.strokeStyle = '#FFFFFF';
378
  ctx.lineWidth = 2;
379
+ [{ x: tempLine.x1, y: tempLine.y1 }, { x: tempLine.x2, y: tempLine.y2 }].forEach(point => {
 
 
 
 
 
380
  ctx.beginPath();
381
+ ctx.arc(point.x, point.y, 8, 0, 2 * Math.PI);
382
  ctx.fill();
383
  ctx.stroke();
384
+ });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
385
  }
386
+ }
387
+
388
+ // WebSocket connection
389
+ function connectWebSocket() {
390
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
391
+ ws = new WebSocket(`${protocol}//${window.location.host}/video_feed`);
392
 
393
+ ws.onopen = () => {
394
+ console.log('WebSocket connected');
395
+ statusIndicator.textContent = 'Live';
396
+ statusIndicator.className = 'text-xs text-emerald-500 animate-pulse font-normal';
397
+ };
398
+
399
+ ws.onmessage = (event) => {
400
+ const msg = JSON.parse(event.data);
401
+ if (msg.type === 'frame') {
402
+ const img = new Image();
403
+ img.onload = () => {
404
+ canvas.width = img.width;
405
+ canvas.height = img.height;
406
+ ctx.drawImage(img, 0, 0);
407
+ drawOverlays();
408
+ };
409
+ img.src = msg.data;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  }
411
+ };
412
+
413
+ ws.onerror = (error) => {
414
+ console.error('WebSocket error:', error);
415
+ statusIndicator.textContent = 'Error';
416
+ statusIndicator.className = 'text-xs text-red-500 font-normal';
417
+ };
418
+
419
+ ws.onclose = () => {
420
+ console.log('WebSocket closed');
421
+ statusIndicator.textContent = 'Ready';
422
+ statusIndicator.className = 'text-xs text-slate-400 font-normal';
423
+ };
424
+ }
425
 
426
+ // Start camera
427
+ async function startCamera() {
428
+ const deviceId = cameraSelect.value;
429
+
430
+ try {
431
+ const constraints = {
432
+ video: {
433
+ width: { ideal: 1280 },
434
+ height: { ideal: 720 },
435
+ facingMode: deviceId ? undefined : 'environment',
436
+ deviceId: deviceId ? { exact: deviceId } : undefined
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
437
  }
438
  };
439
+
440
+ stream = await navigator.mediaDevices.getUserMedia(constraints);
441
+ video.srcObject = stream;
 
442
 
443
+ await video.play();
444
+ canvas.width = video.videoWidth;
445
+ canvas.height = video.videoHeight;
 
 
 
 
 
 
446
 
447
+ cameraPlaceholder.classList.add('hidden');
448
+ liveIndicator.classList.remove('hidden');
449
 
450
+ connectWebSocket();
451
+ sendFrames();
 
 
 
452
 
453
+ startBtn.disabled = true;
454
+ stopBtn.disabled = false;
455
+ drawLineBtn.disabled = false;
456
+ toggleGridBtn.disabled = false;
457
+ cameraSelect.disabled = true;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
 
459
+ hintText.textContent = 'Click "Show Grid" for reference, then "Draw Line" to set counting line.';
460
+ 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';
461
+ } catch (err) {
462
+ console.error('Camera error:', err);
463
+ alert('Cannot access camera. Please check permissions.');
464
+ }
465
+ }
 
 
 
 
 
 
466
 
467
+ // Send frames to server
468
+ function sendFrames() {
469
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
470
  animationId = requestAnimationFrame(sendFrames);
471
+ return;
472
  }
473
 
474
+ if (!isDrawingMode) {
475
+ ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
476
+ const frameData = canvas.toDataURL('image/jpeg', 0.8);
 
 
 
 
 
 
 
 
 
 
 
477
 
478
+ ws.send(JSON.stringify({
479
+ type: 'frame',
480
+ data: frameData
481
+ }));
 
 
 
 
482
  }
483
 
484
+ animationId = requestAnimationFrame(sendFrames);
485
+ }
486
+
487
+ // Stop camera
488
+ function stopCamera() {
489
+ if (stream) {
490
+ stream.getTracks().forEach(track => track.stop());
491
+ stream = null;
492
+ }
493
+ if (ws) {
494
+ ws.close();
495
+ ws = null;
496
+ }
497
+ if (animationId) {
498
+ cancelAnimationFrame(animationId);
499
+ animationId = null;
500
  }
501
 
502
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
503
+ cameraPlaceholder.classList.remove('hidden');
504
+ liveIndicator.classList.add('hidden');
505
+
506
+ startBtn.disabled = false;
507
+ stopBtn.disabled = true;
508
+ drawLineBtn.disabled = true;
509
+ toggleGridBtn.disabled = true;
510
+ cameraSelect.disabled = false;
511
+
512
+ statusIndicator.textContent = 'Ready';
513
+ statusIndicator.className = 'text-xs text-slate-400 font-normal';
514
+ hintText.textContent = 'Click "Start Camera" to begin.';
515
+ 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';
516
+ showGrid = false;
517
+ currentLine = null;
518
+ lineInfo.textContent = 'No lines drawn';
519
+ }
520
+
521
+ // Toggle grid
522
+ function toggleGrid() {
523
+ showGrid = !showGrid;
524
+
525
+ if (showGrid) {
526
+ toggleGridBtn.innerHTML = `
527
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
528
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
529
+ </svg>
530
+ Hide Grid
531
+ `;
532
+ 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';
533
+ hintText.textContent = '✅ Grid enabled! Use as reference for accurate line placement.';
534
+ } else {
535
+ toggleGridBtn.innerHTML = `
536
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
537
+ <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>
538
+ </svg>
539
+ Show Grid
540
+ `;
541
+ 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';
542
+ hintText.textContent = 'Click "Draw Line" to set counting line.';
543
+ }
544
+ }
545
+
546
+ // Toggle draw line mode
547
+ function toggleDrawLine() {
548
+ isDrawingMode = !isDrawingMode;
549
+
550
+ if (isDrawingMode) {
551
+ canvas.classList.add('drawing');
552
+ drawLineBtn.innerHTML = `
553
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
554
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
555
+ </svg>
556
+ Cancel
557
+ `;
558
+ 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';
559
+ drawingIndicator.classList.remove('hidden');
560
+ hintText.textContent = '📍 Click 2 points on video to draw counting line. Use grid as reference.';
561
+ 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';
562
+ linePoints = [];
563
+ tempLine = null;
564
+ } else {
565
+ canvas.classList.remove('drawing');
566
+ drawLineBtn.innerHTML = `
567
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
568
+ <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>
569
+ </svg>
570
+ Draw Line
571
+ `;
572
+ 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';
573
+ drawingIndicator.classList.add('hidden');
574
+ hintText.textContent = 'Click "Draw Line" to set a new counting line.';
575
+ 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';
576
+ linePoints = [];
577
+ tempLine = null;
578
+ }
579
+ }
580
+
581
+ // Canvas click handler
582
+ canvas.addEventListener('click', (e) => {
583
+ if (!isDrawingMode) return;
584
+
585
+ const rect = canvas.getBoundingClientRect();
586
+ const scaleX = canvas.width / rect.width;
587
+ const scaleY = canvas.height / rect.height;
588
+
589
+ const x = (e.clientX - rect.left) * scaleX;
590
+ const y = (e.clientY - rect.top) * scaleY;
591
+
592
+ linePoints.push({ x, y });
593
+
594
+ if (linePoints.length === 1) {
595
+ hintText.textContent = '📍 Click second point to complete counting line.';
596
+ tempLine = { x1: x, y1: y, x2: x, y2: y };
597
  }
598
 
599
+ if (linePoints.length === 2) {
600
+ const line = [
601
+ linePoints[0].x / canvas.width,
602
+ linePoints[0].y / canvas.height,
603
+ linePoints[1].x / canvas.width,
604
+ linePoints[1].y / canvas.height
605
+ ];
606
+
607
+ currentLine = line;
608
+ lineInfo.textContent = '1 line drawn';
609
+
610
+ if (ws && ws.readyState === WebSocket.OPEN) {
611
+ ws.send(JSON.stringify({
612
+ type: 'set_line',
613
+ line: line
614
+ }));
615
  }
616
 
617
+ console.log('Line created:', line);
618
+
619
+ isDrawingMode = false;
620
+ canvas.classList.remove('drawing');
621
+ drawLineBtn.innerHTML = `
622
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
623
+ <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>
624
+ </svg>
625
+ Draw Line
626
+ `;
627
+ 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';
628
+ drawingIndicator.classList.add('hidden');
629
+ hintText.textContent = '✅ Counting line created! Objects crossing this line will be counted.';
630
+ 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';
631
+ linePoints = [];
632
+ tempLine = null;
633
+ }
634
+ });
635
+
636
+ // Canvas mousemove handler
637
+ canvas.addEventListener('mousemove', (e) => {
638
+ if (!isDrawingMode || linePoints.length !== 1) return;
 
 
 
 
 
 
 
 
 
 
 
639
 
640
+ const rect = canvas.getBoundingClientRect();
641
+ const scaleX = canvas.width / rect.width;
642
+ const scaleY = canvas.height / rect.height;
643
+
644
+ const x = (e.clientX - rect.left) * scaleX;
645
+ const y = (e.clientY - rect.top) * scaleY;
646
+
647
+ tempLine = {
648
+ x1: linePoints[0].x,
649
+ y1: linePoints[0].y,
650
+ x2: x,
651
+ y2: y
652
+ };
653
+ });
654
+
655
+ // Touch support
656
+ canvas.addEventListener('touchstart', (e) => {
657
+ e.preventDefault();
658
+ const touch = e.touches[0];
659
+ const mouseEvent = new MouseEvent('click', {
660
+ clientX: touch.clientX,
661
+ clientY: touch.clientY
662
+ });
663
+ canvas.dispatchEvent(mouseEvent);
664
+ });
665
+
666
+ canvas.addEventListener('touchmove', (e) => {
667
+ e.preventDefault();
668
+ const touch = e.touches[0];
669
+ const mouseEvent = new MouseEvent('mousemove', {
670
+ clientX: touch.clientX,
671
+ clientY: touch.clientY
672
  });
673
+ canvas.dispatchEvent(mouseEvent);
674
+ });
675
+
676
+ // Clear line
677
+ async function clearLine() {
678
+ if (!currentLine && !confirm('No line to clear. Reset count anyway?')) return;
679
 
680
+ currentLine = null;
681
+ lineInfo.textContent = 'No lines drawn';
682
+
683
+ if (ws && ws.readyState === WebSocket.OPEN) {
684
+ ws.send(JSON.stringify({
685
+ type: 'set_line',
686
+ line: []
687
+ }));
 
 
 
688
  }
689
+
690
+ hintText.textContent = 'Line cleared. Click "Draw Line" to set a new counting line.';
691
+ 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';
692
+ }
693
+
694
+ // Reset count
695
+ async function resetCount() {
696
+ if (confirm('Reset count to 0?')) {
697
  try {
698
+ await fetch('/count', { method: 'DELETE' });
699
+ document.getElementById('totalCount').textContent = '0';
700
 
701
+ // Clear chart
702
+ countChart.data.labels = [];
703
+ countChart.data.datasets[0].data = [];
704
+ countChart.update();
705
+
706
+ hintText.textContent = '✅ Count reset to 0!';
707
+ 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';
708
+
709
+ setTimeout(() => {
710
+ hintText.textContent = 'Objects crossing the line will be counted.';
711
+ 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';
712
+ }, 3000);
713
  } catch (error) {
714
+ console.error('Error resetting count:', error);
715
+ alert('Failed to reset count. Please try again.');
716
  }
717
  }
718
+ }
719
+
720
+ // Update chart with new count
721
+ function updateChart(count) {
722
+ const now = new Date();
723
+ const timeLabel = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
724
+
725
+ countChart.data.labels.push(timeLabel);
726
+ countChart.data.datasets[0].data.push(count);
727
 
728
+ // Keep only last 20 data points
729
+ if (countChart.data.labels.length > 20) {
730
+ countChart.data.labels.shift();
731
+ countChart.data.datasets[0].data.shift();
732
+ }
733
 
734
+ countChart.update('none'); // Update without animation for smoother performance
735
+ }
736
+
737
+ // Update stats
738
+ async function updateStats() {
739
+ try {
740
+ const response = await fetch('/count');
741
+ const data = await response.json();
742
+
743
+ document.getElementById('totalCount').textContent = data.total || 0;
744
+ document.getElementById('fpsValue').textContent = data.fps || 0;
745
+
746
+ if (data.total !== undefined) {
747
+ updateChart(data.total);
748
+ }
749
+ } catch (error) {
750
+ console.error('Error fetching stats:', error);
751
+ }
752
+ }
753
+
754
+ // Event listeners
755
+ startBtn.addEventListener('click', startCamera);
756
+ stopBtn.addEventListener('click', stopCamera);
757
+ drawLineBtn.addEventListener('click', toggleDrawLine);
758
+ toggleGridBtn.addEventListener('click', toggleGrid);
759
+ resetCountBtn.addEventListener('click', resetCount);
760
+ clearLineBtn.addEventListener('click', clearLine);
761
+
762
+ // Initialize
763
+ getCameras();
764
+
765
+ // Update stats every second
766
+ setInterval(updateStats, 1000);
767
+
768
+ // Dark mode detection
769
+ if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
770
+ document.documentElement.classList.add('dark');
771
+ }
772
+ </script>
773
+ </body>
774
  </html>