Spaces:
Running
Running
Tối ưu lại code này cho đơn giản để có biểu đồ khi đếm được số lượng và có thể chọn camera trong hệ thống trình duyệt và khi vẽ gửi về server thì sẽ hiển thị trên frontend
Browse files- README.md +7 -4
- index.html +697 -19
- style.css +169 -17
README.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
-
emoji: 🚀
|
| 4 |
colorFrom: blue
|
| 5 |
-
colorTo:
|
|
|
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: YOLO Vision Tracker 👁️🗨️
|
|
|
|
| 3 |
colorFrom: blue
|
| 4 |
+
colorTo: purple
|
| 5 |
+
emoji: 🐳
|
| 6 |
sdk: static
|
| 7 |
pinned: false
|
| 8 |
+
tags:
|
| 9 |
+
- deepsite-v3
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Welcome to your new DeepSite project!
|
| 13 |
+
This project was created with [DeepSite](https://huggingface.co/deepsite).
|
index.html
CHANGED
|
@@ -1,19 +1,697 @@
|
|
| 1 |
-
<!
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
.container {
|
| 10 |
+
background: white;
|
| 11 |
+
border-radius: 16px;
|
| 12 |
+
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
| 13 |
+
overflow: hidden;
|
| 14 |
+
max-width: 1200px;
|
| 15 |
+
width: 100%;
|
| 16 |
+
}
|
| 17 |
+
.header {
|
| 18 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 19 |
+
color: white;
|
| 20 |
+
padding: 24px;
|
| 21 |
+
text-align: center;
|
| 22 |
+
}
|
| 23 |
+
h1 {
|
| 24 |
+
font-size: 28px;
|
| 25 |
+
font-weight: 700;
|
| 26 |
+
margin-bottom: 8px;
|
| 27 |
+
}
|
| 28 |
+
.status {
|
| 29 |
+
font-size: 14px;
|
| 30 |
+
opacity: 0.9;
|
| 31 |
+
}
|
| 32 |
+
.content {
|
| 33 |
+
padding: 24px;
|
| 34 |
+
}
|
| 35 |
+
.video-container {
|
| 36 |
+
position: relative;
|
| 37 |
+
background: #000;
|
| 38 |
+
border-radius: 12px;
|
| 39 |
+
overflow: hidden;
|
| 40 |
+
margin-bottom: 24px;
|
| 41 |
+
}
|
| 42 |
+
#outputCanvas {
|
| 43 |
+
width: 100%;
|
| 44 |
+
height: auto;
|
| 45 |
+
display: block;
|
| 46 |
+
cursor: crosshair;
|
| 47 |
+
}
|
| 48 |
+
#outputCanvas.drawing {
|
| 49 |
+
cursor: crosshair;
|
| 50 |
+
}
|
| 51 |
+
#videoInput {
|
| 52 |
+
display: none;
|
| 53 |
+
}
|
| 54 |
+
.controls {
|
| 55 |
+
text-align: center;
|
| 56 |
+
margin-bottom: 24px;
|
| 57 |
+
display: flex;
|
| 58 |
+
gap: 12px;
|
| 59 |
+
justify-content: center;
|
| 60 |
+
flex-wrap: wrap;
|
| 61 |
+
}
|
| 62 |
+
button {
|
| 63 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 64 |
+
color: white;
|
| 65 |
+
border: none;
|
| 66 |
+
padding: 12px 32px;
|
| 67 |
+
border-radius: 8px;
|
| 68 |
+
font-size: 16px;
|
| 69 |
+
font-weight: 600;
|
| 70 |
+
cursor: pointer;
|
| 71 |
+
transition: transform 0.2s;
|
| 72 |
+
}
|
| 73 |
+
button:hover {
|
| 74 |
+
transform: translateY(-2px);
|
| 75 |
+
}
|
| 76 |
+
button:disabled {
|
| 77 |
+
opacity: 0.5;
|
| 78 |
+
cursor: not-allowed;
|
| 79 |
+
}
|
| 80 |
+
button.draw-mode {
|
| 81 |
+
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
| 82 |
+
}
|
| 83 |
+
button.grid-mode {
|
| 84 |
+
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
| 85 |
+
}
|
| 86 |
+
.hint {
|
| 87 |
+
text-align: center;
|
| 88 |
+
color: #666;
|
| 89 |
+
font-size: 14px;
|
| 90 |
+
margin-bottom: 16px;
|
| 91 |
+
padding: 8px;
|
| 92 |
+
background: #f0f0f0;
|
| 93 |
+
border-radius: 8px;
|
| 94 |
+
}
|
| 95 |
+
.hint.active {
|
| 96 |
+
background: #fff3cd;
|
| 97 |
+
color: #856404;
|
| 98 |
+
}
|
| 99 |
+
.stats {
|
| 100 |
+
display: grid;
|
| 101 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 102 |
+
gap: 16px;
|
| 103 |
+
}
|
| 104 |
+
.stat-card {
|
| 105 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 106 |
+
color: white;
|
| 107 |
+
padding: 20px;
|
| 108 |
+
border-radius: 12px;
|
| 109 |
+
text-align: center;
|
| 110 |
+
}
|
| 111 |
+
.stat-label {
|
| 112 |
+
font-size: 14px;
|
| 113 |
+
opacity: 0.9;
|
| 114 |
+
margin-bottom: 8px;
|
| 115 |
+
}
|
| 116 |
+
.stat-value {
|
| 117 |
+
font-size: 32px;
|
| 118 |
+
font-weight: 700;
|
| 119 |
+
}
|
| 120 |
+
.footer {
|
| 121 |
+
text-align: center;
|
| 122 |
+
padding: 16px;
|
| 123 |
+
color: #666;
|
| 124 |
+
font-size: 14px;
|
| 125 |
+
}
|
| 126 |
+
@media (max-width: 768px) {
|
| 127 |
+
h1 { font-size: 24px; }
|
| 128 |
+
.stat-value { font-size: 28px; }
|
| 129 |
+
}
|
| 130 |
+
</style>
|
| 131 |
+
</head>
|
| 132 |
+
<body>
|
| 133 |
+
<div class="container">
|
| 134 |
+
<div class="header">
|
| 135 |
+
<h1>📹 YOLO Detection - Browser Camera</h1>
|
| 136 |
+
<div class="status">Real-time Object Detection from Your Camera</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
<div class="content">
|
| 140 |
+
<div class="controls">
|
| 141 |
+
<select id="cameraSelect" class="camera-select">
|
| 142 |
+
<option value="">-- Select Camera --</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 |
+
|
| 178 |
+
<div class="footer">
|
| 179 |
+
Powered by FastAPI + YOLO | Browser Camera Detection
|
| 180 |
+
</div>
|
| 181 |
+
</div>
|
| 182 |
+
|
| 183 |
+
<script>
|
| 184 |
+
const video = document.getElementById('videoInput');
|
| 185 |
+
const canvas = document.getElementById('outputCanvas');
|
| 186 |
+
const ctx = canvas.getContext('2d');
|
| 187 |
+
const startBtn = document.getElementById('startBtn');
|
| 188 |
+
const stopBtn = document.getElementById('stopBtn');
|
| 189 |
+
const drawLineBtn = document.getElementById('drawLineBtn');
|
| 190 |
+
const toggleGridBtn = document.getElementById('toggleGridBtn');
|
| 191 |
+
const resetCountBtn = document.getElementById('resetCountBtn');
|
| 192 |
+
const statusText = document.getElementById('statusText');
|
| 193 |
+
const hintText = document.getElementById('hintText');
|
| 194 |
+
|
| 195 |
+
let ws = null;
|
| 196 |
+
let stream = null;
|
| 197 |
+
let animationId = null;
|
| 198 |
+
let isDrawingMode = false;
|
| 199 |
+
let showGrid = false;
|
| 200 |
+
let linePoints = [];
|
| 201 |
+
let tempLine = null;
|
| 202 |
+
let currentLine = null; // Lưu line đã vẽ
|
| 203 |
+
|
| 204 |
+
// Vẽ tất cả overlays (grid + lines)
|
| 205 |
+
function drawOverlays() {
|
| 206 |
+
drawGrid();
|
| 207 |
+
drawCountLine();
|
| 208 |
+
drawTempLine();
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
// Vẽ line đã vẽ (COUNT LINE)
|
| 212 |
+
function drawCountLine() {
|
| 213 |
+
if (!currentLine) return;
|
| 214 |
+
|
| 215 |
+
const w = canvas.width;
|
| 216 |
+
const h = canvas.height;
|
| 217 |
+
const x1 = currentLine[0] * w;
|
| 218 |
+
const y1 = currentLine[1] * h;
|
| 219 |
+
const x2 = currentLine[2] * w;
|
| 220 |
+
const y2 = currentLine[3] * h;
|
| 221 |
+
|
| 222 |
+
// Vẽ line chính màu xanh dương đậm
|
| 223 |
+
ctx.strokeStyle = '#00FF00'; // Màu xanh lá neon
|
| 224 |
+
ctx.lineWidth = 5;
|
| 225 |
+
ctx.setLineDash([]);
|
| 226 |
+
ctx.shadowColor = '#00FF00';
|
| 227 |
+
ctx.shadowBlur = 10;
|
| 228 |
+
ctx.beginPath();
|
| 229 |
+
ctx.moveTo(x1, y1);
|
| 230 |
+
ctx.lineTo(x2, y2);
|
| 231 |
+
ctx.stroke();
|
| 232 |
+
ctx.shadowBlur = 0;
|
| 233 |
+
|
| 234 |
+
// Vẽ điểm endpoint
|
| 235 |
+
ctx.fillStyle = '#00FF00';
|
| 236 |
+
ctx.strokeStyle = '#FFFFFF';
|
| 237 |
+
ctx.lineWidth = 2;
|
| 238 |
+
ctx.beginPath();
|
| 239 |
+
ctx.arc(x1, y1, 10, 0, 2 * Math.PI);
|
| 240 |
+
ctx.fill();
|
| 241 |
+
ctx.stroke();
|
| 242 |
+
ctx.beginPath();
|
| 243 |
+
ctx.arc(x2, y2, 10, 0, 2 * Math.PI);
|
| 244 |
+
ctx.fill();
|
| 245 |
+
ctx.stroke();
|
| 246 |
+
|
| 247 |
+
// Label với background
|
| 248 |
+
ctx.font = 'bold 18px Arial';
|
| 249 |
+
const labelText = '⚡ COUNT LINE';
|
| 250 |
+
const labelX = (x1 + x2) / 2;
|
| 251 |
+
const labelY = (y1 + y2) / 2 - 15;
|
| 252 |
+
const textWidth = ctx.measureText(labelText).width;
|
| 253 |
+
|
| 254 |
+
// Background
|
| 255 |
+
ctx.fillStyle = 'rgba(0, 255, 0, 0.8)';
|
| 256 |
+
ctx.fillRect(labelX - textWidth/2 - 10, labelY - 25, textWidth + 20, 35);
|
| 257 |
+
|
| 258 |
+
// Text
|
| 259 |
+
ctx.fillStyle = '#000000';
|
| 260 |
+
ctx.fillText(labelText, labelX - textWidth/2, labelY);
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
// Vẽ line tạm (preview khi đang vẽ)
|
| 264 |
+
function drawTempLine() {
|
| 265 |
+
if (!isDrawingMode || !tempLine) return;
|
| 266 |
+
|
| 267 |
+
ctx.strokeStyle = '#FF0000';
|
| 268 |
+
ctx.lineWidth = 3;
|
| 269 |
+
ctx.setLineDash([10, 5]);
|
| 270 |
+
ctx.beginPath();
|
| 271 |
+
ctx.moveTo(tempLine.x1, tempLine.y1);
|
| 272 |
+
ctx.lineTo(tempLine.x2, tempLine.y2);
|
| 273 |
+
ctx.stroke();
|
| 274 |
+
ctx.setLineDash([]);
|
| 275 |
+
|
| 276 |
+
// Vẽ điểm
|
| 277 |
+
ctx.fillStyle = '#FF0000';
|
| 278 |
+
ctx.strokeStyle = '#FFFFFF';
|
| 279 |
+
ctx.lineWidth = 2;
|
| 280 |
+
ctx.beginPath();
|
| 281 |
+
ctx.arc(tempLine.x1, tempLine.y1, 8, 0, 2 * Math.PI);
|
| 282 |
+
ctx.fill();
|
| 283 |
+
ctx.stroke();
|
| 284 |
+
|
| 285 |
+
if (tempLine.x2 !== undefined) {
|
| 286 |
+
ctx.beginPath();
|
| 287 |
+
ctx.arc(tempLine.x2, tempLine.y2, 8, 0, 2 * Math.PI);
|
| 288 |
+
ctx.fill();
|
| 289 |
+
ctx.stroke();
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
// Hiển thị tọa độ
|
| 293 |
+
ctx.font = 'bold 14px Arial';
|
| 294 |
+
const coord1 = `(${Math.round(tempLine.x1)}, ${Math.round(tempLine.y1)})`;
|
| 295 |
+
|
| 296 |
+
// Background cho text
|
| 297 |
+
const textWidth1 = ctx.measureText(coord1).width;
|
| 298 |
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
| 299 |
+
ctx.fillRect(tempLine.x1 + 5, tempLine.y1 - 25, textWidth1 + 10, 20);
|
| 300 |
+
|
| 301 |
+
ctx.fillStyle = '#FFFFFF';
|
| 302 |
+
ctx.fillText(coord1, tempLine.x1 + 10, tempLine.y1 - 10);
|
| 303 |
+
|
| 304 |
+
if (tempLine.x2 !== undefined) {
|
| 305 |
+
const coord2 = `(${Math.round(tempLine.x2)}, ${Math.round(tempLine.y2)})`;
|
| 306 |
+
const textWidth2 = ctx.measureText(coord2).width;
|
| 307 |
+
|
| 308 |
+
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
|
| 309 |
+
ctx.fillRect(tempLine.x2 + 5, tempLine.y2 - 25, textWidth2 + 10, 20);
|
| 310 |
+
|
| 311 |
+
ctx.fillStyle = '#FFFFFF';
|
| 312 |
+
ctx.fillText(coord2, tempLine.x2 + 10, tempLine.y2 - 10);
|
| 313 |
+
}
|
| 314 |
+
}
|
| 315 |
+
|
| 316 |
+
// Vẽ grid lines
|
| 317 |
+
function drawGrid() {
|
| 318 |
+
if (!showGrid) return;
|
| 319 |
+
|
| 320 |
+
const w = canvas.width;
|
| 321 |
+
const h = canvas.height;
|
| 322 |
+
|
| 323 |
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
|
| 324 |
+
ctx.lineWidth = 1;
|
| 325 |
+
ctx.setLineDash([5, 5]);
|
| 326 |
+
|
| 327 |
+
// Vẽ 9 ô (3x3 grid)
|
| 328 |
+
for (let i = 1; i <= 2; i++) {
|
| 329 |
+
// Vertical lines
|
| 330 |
+
ctx.beginPath();
|
| 331 |
+
ctx.moveTo(w * i / 3, 0);
|
| 332 |
+
ctx.lineTo(w * i / 3, h);
|
| 333 |
+
ctx.stroke();
|
| 334 |
+
|
| 335 |
+
// Horizontal lines
|
| 336 |
+
ctx.beginPath();
|
| 337 |
+
ctx.moveTo(0, h * i / 3);
|
| 338 |
+
ctx.lineTo(w, h * i / 3);
|
| 339 |
+
ctx.stroke();
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
// Center cross (highlight)
|
| 343 |
+
ctx.strokeStyle = 'rgba(0, 255, 0, 0.4)';
|
| 344 |
+
ctx.lineWidth = 2;
|
| 345 |
+
|
| 346 |
+
// Center vertical
|
| 347 |
+
ctx.beginPath();
|
| 348 |
+
ctx.moveTo(w / 2, 0);
|
| 349 |
+
ctx.lineTo(w / 2, h);
|
| 350 |
+
ctx.stroke();
|
| 351 |
+
|
| 352 |
+
// Center horizontal
|
| 353 |
+
ctx.beginPath();
|
| 354 |
+
ctx.moveTo(0, h / 2);
|
| 355 |
+
ctx.lineTo(w, h / 2);
|
| 356 |
+
ctx.stroke();
|
| 357 |
+
|
| 358 |
+
// Reset
|
| 359 |
+
ctx.setLineDash([]);
|
| 360 |
+
}
|
| 361 |
+
// Chart setup
|
| 362 |
+
const countChart = new Chart(
|
| 363 |
+
document.getElementById('countChart'),
|
| 364 |
+
{
|
| 365 |
+
type: 'line',
|
| 366 |
+
data: {
|
| 367 |
+
labels: [],
|
| 368 |
+
datasets: [{
|
| 369 |
+
label: 'Objects Count',
|
| 370 |
+
data: [],
|
| 371 |
+
borderColor: '#667eea',
|
| 372 |
+
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
| 373 |
+
tension: 0.1,
|
| 374 |
+
fill: true
|
| 375 |
+
}]
|
| 376 |
+
},
|
| 377 |
+
options: {
|
| 378 |
+
responsive: true,
|
| 379 |
+
scales: {
|
| 380 |
+
y: {
|
| 381 |
+
beginAtZero: true
|
| 382 |
+
}
|
| 383 |
+
}
|
| 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 |
+
statusText.innerHTML = '🟢 Active';
|
| 396 |
+
};
|
| 397 |
+
|
| 398 |
+
ws.onmessage = (event) => {
|
| 399 |
+
const msg = JSON.parse(event.data);
|
| 400 |
+
if (msg.type === 'frame') {
|
| 401 |
+
const img = new Image();
|
| 402 |
+
img.onload = () => {
|
| 403 |
+
canvas.width = img.width;
|
| 404 |
+
canvas.height = img.height;
|
| 405 |
+
|
| 406 |
+
// Vẽ frame từ server
|
| 407 |
+
ctx.drawImage(img, 0, 0);
|
| 408 |
+
|
| 409 |
+
// Vẽ overlays (grid + lines) lên trên frame
|
| 410 |
+
drawOverlays();
|
| 411 |
+
};
|
| 412 |
+
img.src = msg.data;
|
| 413 |
+
} else if (msg.type === 'count_update') {
|
| 414 |
+
updateChart(msg.count);
|
| 415 |
+
}
|
| 416 |
+
};
|
| 417 |
+
ws.onerror = (error) => {
|
| 418 |
+
console.error('WebSocket error:', error);
|
| 419 |
+
statusText.innerHTML = '🔴 Error';
|
| 420 |
+
};
|
| 421 |
+
|
| 422 |
+
ws.onclose = () => {
|
| 423 |
+
console.log('WebSocket closed');
|
| 424 |
+
statusText.innerHTML = '⚪ Ready';
|
| 425 |
+
};
|
| 426 |
+
}
|
| 427 |
+
// Update chart with new count
|
| 428 |
+
function updateChart(count) {
|
| 429 |
+
const now = new Date();
|
| 430 |
+
const timeLabel = `${now.getHours()}:${now.getMinutes()}:${now.getSeconds()}`;
|
| 431 |
+
|
| 432 |
+
countChart.data.labels.push(timeLabel);
|
| 433 |
+
countChart.data.datasets[0].data.push(count);
|
| 434 |
+
|
| 435 |
+
// Keep only last 20 data points
|
| 436 |
+
if (countChart.data.labels.length > 20) {
|
| 437 |
+
countChart.data.labels.shift();
|
| 438 |
+
countChart.data.datasets[0].data.shift();
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
countChart.update();
|
| 442 |
+
}
|
| 443 |
+
|
| 444 |
+
// Get available cameras
|
| 445 |
+
async function getCameras() {
|
| 446 |
+
try {
|
| 447 |
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
| 448 |
+
const videoDevices = devices.filter(device => device.kind === 'videoinput');
|
| 449 |
+
|
| 450 |
+
const cameraSelect = document.getElementById('cameraSelect');
|
| 451 |
+
cameraSelect.innerHTML = '<option value="">-- Select Camera --</option>';
|
| 452 |
+
|
| 453 |
+
videoDevices.forEach(device => {
|
| 454 |
+
const option = document.createElement('option');
|
| 455 |
+
option.value = device.deviceId;
|
| 456 |
+
option.text = device.label || `Camera ${cameraSelect.length}`;
|
| 457 |
+
cameraSelect.appendChild(option);
|
| 458 |
+
});
|
| 459 |
+
} catch (err) {
|
| 460 |
+
console.error('Error enumerating devices:', err);
|
| 461 |
+
}
|
| 462 |
+
}
|
| 463 |
+
|
| 464 |
+
// Start camera
|
| 465 |
+
async function startCamera() {
|
| 466 |
+
const cameraSelect = document.getElementById('cameraSelect');
|
| 467 |
+
const deviceId = cameraSelect.value;
|
| 468 |
+
|
| 469 |
+
if (!deviceId) {
|
| 470 |
+
alert('Please select a camera first');
|
| 471 |
+
return;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
try {
|
| 475 |
+
stream = await navigator.mediaDevices.getUserMedia({
|
| 476 |
+
video: {
|
| 477 |
+
deviceId: { exact: deviceId },
|
| 478 |
+
width: { ideal: 1280 },
|
| 479 |
+
height: { ideal: 720 }
|
| 480 |
+
}
|
| 481 |
+
});
|
| 482 |
+
video.srcObject = stream;
|
| 483 |
+
|
| 484 |
+
await video.play();
|
| 485 |
+
canvas.width = video.videoWidth;
|
| 486 |
+
canvas.height = video.videoHeight;
|
| 487 |
+
|
| 488 |
+
connectWebSocket();
|
| 489 |
+
sendFrames();
|
| 490 |
+
|
| 491 |
+
startBtn.disabled = true;
|
| 492 |
+
stopBtn.disabled = false;
|
| 493 |
+
drawLineBtn.disabled = false;
|
| 494 |
+
toggleGridBtn.disabled = false;
|
| 495 |
+
hintText.textContent = 'Nhấn "Show Grid" để hiển thị lưới, sau đó "Draw Line" để vẽ vạch đếm.';
|
| 496 |
+
} catch (err) {
|
| 497 |
+
console.error('Camera error:', err);
|
| 498 |
+
alert('Cannot access camera. Please check permissions.');
|
| 499 |
+
}
|
| 500 |
+
}
|
| 501 |
+
|
| 502 |
+
// Send frames to server
|
| 503 |
+
function sendFrames() {
|
| 504 |
+
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
| 505 |
+
animationId = requestAnimationFrame(sendFrames);
|
| 506 |
+
return;
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
// Không gửi frame khi đang vẽ line để tránh lag
|
| 510 |
+
if (!isDrawingMode) {
|
| 511 |
+
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
| 512 |
+
const frameData = canvas.toDataURL('image/jpeg', 0.8);
|
| 513 |
+
|
| 514 |
+
ws.send(JSON.stringify({
|
| 515 |
+
type: 'frame',
|
| 516 |
+
data: frameData
|
| 517 |
+
}));
|
| 518 |
+
}
|
| 519 |
+
|
| 520 |
+
animationId = requestAnimationFrame(sendFrames);
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
// Stop camera
|
| 524 |
+
function stopCamera() {
|
| 525 |
+
if (stream) {
|
| 526 |
+
stream.getTracks().forEach(track => track.stop());
|
| 527 |
+
stream = null;
|
| 528 |
+
}
|
| 529 |
+
if (ws) {
|
| 530 |
+
ws.close();
|
| 531 |
+
ws = null;
|
| 532 |
+
}
|
| 533 |
+
if (animationId) {
|
| 534 |
+
cancelAnimationFrame(animationId);
|
| 535 |
+
animationId = null;
|
| 536 |
+
}
|
| 537 |
+
|
| 538 |
+
startBtn.disabled = false;
|
| 539 |
+
stopBtn.disabled = true;
|
| 540 |
+
drawLineBtn.disabled = true;
|
| 541 |
+
toggleGridBtn.disabled = true;
|
| 542 |
+
statusText.innerHTML = '⚪ Ready';
|
| 543 |
+
hintText.textContent = 'Click "Start Camera" để bắt đầu.';
|
| 544 |
+
showGrid = false;
|
| 545 |
+
currentLine = null; // Clear line khi stop
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
// Toggle grid
|
| 549 |
+
function toggleGrid() {
|
| 550 |
+
showGrid = !showGrid;
|
| 551 |
+
|
| 552 |
+
if (showGrid) {
|
| 553 |
+
toggleGridBtn.classList.add('grid-mode');
|
| 554 |
+
toggleGridBtn.textContent = '📐 Hide Grid';
|
| 555 |
+
hintText.textContent = '✅ Lưới đã bật! Dùng làm tham chiếu để vẽ line chính xác.';
|
| 556 |
+
} else {
|
| 557 |
+
toggleGridBtn.classList.remove('grid-mode');
|
| 558 |
+
toggleGridBtn.textContent = '📐 Show Grid';
|
| 559 |
+
hintText.textContent = 'Nhấn "Draw Line" để vẽ vạch đếm vật thể.';
|
| 560 |
+
}
|
| 561 |
+
}
|
| 562 |
+
|
| 563 |
+
// Draw line mode
|
| 564 |
+
function toggleDrawLine() {
|
| 565 |
+
isDrawingMode = !isDrawingMode;
|
| 566 |
+
|
| 567 |
+
if (isDrawingMode) {
|
| 568 |
+
canvas.classList.add('drawing');
|
| 569 |
+
drawLineBtn.classList.add('draw-mode');
|
| 570 |
+
drawLineBtn.textContent = '❌ Cancel Draw';
|
| 571 |
+
hintText.textContent = '📍 Click 2 điểm trên video để vẽ vạch đếm. Dùng lưới làm tham chiếu.';
|
| 572 |
+
hintText.classList.add('active');
|
| 573 |
+
linePoints = [];
|
| 574 |
+
tempLine = null;
|
| 575 |
+
} else {
|
| 576 |
+
canvas.classList.remove('drawing');
|
| 577 |
+
drawLineBtn.classList.remove('draw-mode');
|
| 578 |
+
drawLineBtn.textContent = '✏️ Draw Line';
|
| 579 |
+
hintText.textContent = 'Nhấn "Draw Line" để vẽ vạch đếm vật thể.';
|
| 580 |
+
hintText.classList.remove('active');
|
| 581 |
+
linePoints = [];
|
| 582 |
+
tempLine = null;
|
| 583 |
+
}
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
// Canvas click để vẽ line
|
| 587 |
+
canvas.addEventListener('click', (e) => {
|
| 588 |
+
if (!isDrawingMode) return;
|
| 589 |
+
|
| 590 |
+
const rect = canvas.getBoundingClientRect();
|
| 591 |
+
const scaleX = canvas.width / rect.width;
|
| 592 |
+
const scaleY = canvas.height / rect.height;
|
| 593 |
+
|
| 594 |
+
const x = (e.clientX - rect.left) * scaleX;
|
| 595 |
+
const y = (e.clientY - rect.top) * scaleY;
|
| 596 |
+
|
| 597 |
+
linePoints.push({ x, y });
|
| 598 |
+
|
| 599 |
+
if (linePoints.length === 1) {
|
| 600 |
+
hintText.textContent = '📍 Click điểm thứ 2 để hoàn thành vạch đếm.';
|
| 601 |
+
tempLine = { x1: x, y1: y };
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
if (linePoints.length === 2) {
|
| 605 |
+
// Chuyển đổi sang tỷ lệ % (0-1)
|
| 606 |
+
const line = [
|
| 607 |
+
linePoints[0].x / canvas.width,
|
| 608 |
+
linePoints[0].y / canvas.height,
|
| 609 |
+
linePoints[1].x / canvas.width,
|
| 610 |
+
linePoints[1].y / canvas.height
|
| 611 |
+
];
|
| 612 |
+
|
| 613 |
+
// Lưu line để hiển thị
|
| 614 |
+
currentLine = line;
|
| 615 |
+
|
| 616 |
+
// Gửi line mới đến server
|
| 617 |
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
| 618 |
+
ws.send(JSON.stringify({
|
| 619 |
+
type: 'set_line',
|
| 620 |
+
line: line
|
| 621 |
+
}));
|
| 622 |
+
}
|
| 623 |
+
|
| 624 |
+
console.log('Line created:', line);
|
| 625 |
+
|
| 626 |
+
// Tắt draw mode
|
| 627 |
+
isDrawingMode = false;
|
| 628 |
+
canvas.classList.remove('drawing');
|
| 629 |
+
drawLineBtn.classList.remove('draw-mode');
|
| 630 |
+
drawLineBtn.textContent = '✏️ Draw Line';
|
| 631 |
+
hintText.textContent = '✅ Vạch đếm đã được tạo! Vật thể sẽ được đếm khi chạm vạch.';
|
| 632 |
+
hintText.classList.remove('active');
|
| 633 |
+
linePoints = [];
|
| 634 |
+
tempLine = null;
|
| 635 |
+
}
|
| 636 |
+
});
|
| 637 |
+
|
| 638 |
+
// Canvas mousemove để preview line
|
| 639 |
+
canvas.addEventListener('mousemove', (e) => {
|
| 640 |
+
if (!isDrawingMode || linePoints.length !== 1) return;
|
| 641 |
+
|
| 642 |
+
const rect = canvas.getBoundingClientRect();
|
| 643 |
+
const scaleX = canvas.width / rect.width;
|
| 644 |
+
const scaleY = canvas.height / rect.height;
|
| 645 |
+
|
| 646 |
+
const x = (e.clientX - rect.left) * scaleX;
|
| 647 |
+
const y = (e.clientY - rect.top) * scaleY;
|
| 648 |
+
|
| 649 |
+
tempLine = {
|
| 650 |
+
x1: linePoints[0].x,
|
| 651 |
+
y1: linePoints[0].y,
|
| 652 |
+
x2: x,
|
| 653 |
+
y2: y
|
| 654 |
+
};
|
| 655 |
+
});
|
| 656 |
+
|
| 657 |
+
// Reset count
|
| 658 |
+
async function resetCount() {
|
| 659 |
+
if (confirm('Bạn có chắc muốn reset số lượng đếm về 0?')) {
|
| 660 |
+
try {
|
| 661 |
+
await fetch('/count', { method: 'DELETE' });
|
| 662 |
+
updateStats();
|
| 663 |
+
alert('✅ Đã reset count về 0!');
|
| 664 |
+
} catch (error) {
|
| 665 |
+
console.error('Error resetting count:', error);
|
| 666 |
+
}
|
| 667 |
+
}
|
| 668 |
+
}
|
| 669 |
+
// Update stats
|
| 670 |
+
async function updateStats() {
|
| 671 |
+
try {
|
| 672 |
+
const response = await fetch('/count');
|
| 673 |
+
const data = await response.json();
|
| 674 |
+
|
| 675 |
+
document.getElementById('totalCount').textContent = data.total;
|
| 676 |
+
document.getElementById('fpsValue').textContent = data.fps;
|
| 677 |
+
updateChart(data.total);
|
| 678 |
+
} catch (error) {
|
| 679 |
+
console.error('Error fetching stats:', error);
|
| 680 |
+
}
|
| 681 |
+
}
|
| 682 |
+
// Event listeners
|
| 683 |
+
startBtn.addEventListener('click', startCamera);
|
| 684 |
+
stopBtn.addEventListener('click', stopCamera);
|
| 685 |
+
drawLineBtn.addEventListener('click', toggleDrawLine);
|
| 686 |
+
toggleGridBtn.addEventListener('click', toggleGrid);
|
| 687 |
+
resetCountBtn.addEventListener('click', resetCount);
|
| 688 |
+
|
| 689 |
+
// Initialize
|
| 690 |
+
getCameras();
|
| 691 |
+
|
| 692 |
+
// Update stats every second
|
| 693 |
+
setInterval(updateStats, 1000);
|
| 694 |
+
</script>
|
| 695 |
+
<script src="https://huggingface.co/deepsite/deepsite-badge.js"></script>
|
| 696 |
+
</body>
|
| 697 |
+
</html>
|
style.css
CHANGED
|
@@ -1,28 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
body {
|
| 2 |
-
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
}
|
| 5 |
|
| 6 |
h1 {
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
margin: 0 auto;
|
| 21 |
-
padding: 16px;
|
| 22 |
-
border: 1px solid lightgray;
|
| 23 |
-
border-radius: 16px;
|
| 24 |
}
|
| 25 |
|
| 26 |
-
|
| 27 |
-
|
|
|
|
| 28 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
* {
|
| 2 |
+
margin: 0;
|
| 3 |
+
padding: 0;
|
| 4 |
+
box-sizing: border-box;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
body {
|
| 8 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif;
|
| 9 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 10 |
+
min-height: 100vh;
|
| 11 |
+
display: flex;
|
| 12 |
+
align-items: center;
|
| 13 |
+
justify-content: center;
|
| 14 |
+
padding: 20px;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
.container {
|
| 18 |
+
background: white;
|
| 19 |
+
border-radius: 16px;
|
| 20 |
+
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
| 21 |
+
overflow: hidden;
|
| 22 |
+
max-width: 1200px;
|
| 23 |
+
width: 100%;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.header {
|
| 27 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 28 |
+
color: white;
|
| 29 |
+
padding: 24px;
|
| 30 |
+
text-align: center;
|
| 31 |
}
|
| 32 |
|
| 33 |
h1 {
|
| 34 |
+
font-size: 28px;
|
| 35 |
+
font-weight: 700;
|
| 36 |
+
margin-bottom: 8px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.status {
|
| 40 |
+
font-size: 14px;
|
| 41 |
+
opacity: 0.9;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
.content {
|
| 45 |
+
padding: 24px;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
.video-container {
|
| 49 |
+
position: relative;
|
| 50 |
+
background: #000;
|
| 51 |
+
border-radius: 12px;
|
| 52 |
+
overflow: hidden;
|
| 53 |
+
margin-bottom: 24px;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
#outputCanvas {
|
| 57 |
+
width: 100%;
|
| 58 |
+
height: auto;
|
| 59 |
+
display: block;
|
| 60 |
+
cursor: crosshair;
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
#outputCanvas.drawing {
|
| 64 |
+
cursor: crosshair;
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
#videoInput {
|
| 68 |
+
display: none;
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
.controls {
|
| 72 |
+
text-align: center;
|
| 73 |
+
margin-bottom: 24px;
|
| 74 |
+
display: flex;
|
| 75 |
+
gap: 12px;
|
| 76 |
+
justify-content: center;
|
| 77 |
+
flex-wrap: wrap;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.camera-select {
|
| 81 |
+
padding: 12px;
|
| 82 |
+
border-radius: 8px;
|
| 83 |
+
border: 1px solid #ddd;
|
| 84 |
+
font-size: 16px;
|
| 85 |
+
min-width: 200px;
|
| 86 |
}
|
| 87 |
|
| 88 |
+
button {
|
| 89 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 90 |
+
color: white;
|
| 91 |
+
border: none;
|
| 92 |
+
padding: 12px 32px;
|
| 93 |
+
border-radius: 8px;
|
| 94 |
+
font-size: 16px;
|
| 95 |
+
font-weight: 600;
|
| 96 |
+
cursor: pointer;
|
| 97 |
+
transition: transform 0.2s;
|
| 98 |
}
|
| 99 |
|
| 100 |
+
button:hover {
|
| 101 |
+
transform: translateY(-2px);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
}
|
| 103 |
|
| 104 |
+
button:disabled {
|
| 105 |
+
opacity: 0.5;
|
| 106 |
+
cursor: not-allowed;
|
| 107 |
}
|
| 108 |
+
|
| 109 |
+
button.draw-mode {
|
| 110 |
+
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
button.grid-mode {
|
| 114 |
+
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
.hint {
|
| 118 |
+
text-align: center;
|
| 119 |
+
color: #666;
|
| 120 |
+
font-size: 14px;
|
| 121 |
+
margin-bottom: 16px;
|
| 122 |
+
padding: 8px;
|
| 123 |
+
background: #f0f0f0;
|
| 124 |
+
border-radius: 8px;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.hint.active {
|
| 128 |
+
background: #fff3cd;
|
| 129 |
+
color: #856404;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.stats {
|
| 133 |
+
display: grid;
|
| 134 |
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
| 135 |
+
gap: 16px;
|
| 136 |
+
margin-bottom: 24px;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
.stat-card {
|
| 140 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 141 |
+
color: white;
|
| 142 |
+
padding: 20px;
|
| 143 |
+
border-radius: 12px;
|
| 144 |
+
text-align: center;
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
.stat-label {
|
| 148 |
+
font-size: 14px;
|
| 149 |
+
opacity: 0.9;
|
| 150 |
+
margin-bottom: 8px;
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
.stat-value {
|
| 154 |
+
font-size: 32px;
|
| 155 |
+
font-weight: 700;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
.chart-container {
|
| 159 |
+
background: white;
|
| 160 |
+
padding: 20px;
|
| 161 |
+
border-radius: 12px;
|
| 162 |
+
margin-top: 24px;
|
| 163 |
+
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
| 164 |
+
}
|
| 165 |
+
|
| 166 |
+
.footer {
|
| 167 |
+
text-align: center;
|
| 168 |
+
padding: 16px;
|
| 169 |
+
color: #666;
|
| 170 |
+
font-size: 14px;
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
@media (max-width: 768px) {
|
| 174 |
+
h1 { font-size: 24px; }
|
| 175 |
+
.stat-value { font-size: 28px; }
|
| 176 |
+
.controls {
|
| 177 |
+
flex-direction: column;
|
| 178 |
+
align-items: center;
|
| 179 |
+
}
|
| 180 |
+
}
|