yolo-webgpu / index.html
mr4's picture
Upload 2 files
e66436f verified
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>YOLO Image Detection</title>
<style>
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #f0f2f5;
color: #1a1a2e;
min-height: 100vh;
padding: 24px 16px;
}
h1 {
text-align: center;
font-size: 1.75rem;
font-weight: 700;
margin-bottom: 24px;
color: #1a1a2e;
}
.container {
max-width: 1200px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 20px;
}
/* Status */
#status {
text-align: center;
font-size: 0.95rem;
padding: 10px 16px;
border-radius: 8px;
background: #e8f4fd;
color: #1565c0;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.2s, color 0.2s;
}
#status.loading {
background: #e8f4fd;
color: #1565c0;
}
#status.error {
background: #fdecea;
color: #c62828;
}
#status.ready {
background: #e8f5e9;
color: #2e7d32;
}
#status.processing {
background: #fff8e1;
color: #f57f17;
}
/* Input area */
.input-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
background: #fff;
border-radius: 12px;
padding: 24px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
}
/* Source tabs */
.source-tabs {
display: flex;
gap: 8px;
}
.tab-btn {
padding: 8px 20px;
border: 2px solid #90caf9;
border-radius: 8px;
background: #fff;
color: #1565c0;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.tab-btn.active {
background: #1565c0;
color: #fff;
border-color: #1565c0;
}
.model-selector {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
max-width: 400px;
}
.model-selector label {
font-size: 0.9rem;
font-weight: 600;
color: #555;
white-space: nowrap;
}
#model-select {
flex: 1;
padding: 8px 12px;
border: 1px solid #90caf9;
border-radius: 8px;
font-size: 0.95rem;
color: #1a1a2e;
background: #fff;
cursor: pointer;
}
#model-select:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.file-label {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
padding: 10px 20px;
border: 2px dashed #90caf9;
border-radius: 8px;
color: #1565c0;
font-size: 0.95rem;
transition: border-color 0.2s, background 0.2s;
}
.file-label:hover {
border-color: #1565c0;
background: #e8f4fd;
}
.btn-sample {
background: none;
border: none;
color: #1565c0;
font-size: 0.85rem;
cursor: pointer;
text-decoration: underline;
padding: 2px 4px;
opacity: 0.75;
transition: opacity 0.2s;
}
.btn-sample:hover {
opacity: 1;
}
#file-input,
#video-file-input {
display: none;
}
#detect-btn {
padding: 10px 32px;
font-size: 1rem;
font-weight: 600;
background: #1565c0;
color: #fff;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s, opacity 0.2s;
}
#detect-btn:hover:not(:disabled) {
background: #0d47a1;
}
#detect-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Webcam */
#webcam-panel { display: none; flex-direction: column; align-items: center; gap: 10px; width: 100%; }
#webcam-panel.active { display: flex; }
#video-panel { display: none; flex-direction: column; align-items: center; gap: 10px; width: 100%; }
#video-panel.active { display: flex; }
#image-panel { display: flex; flex-direction: column; align-items: center; gap: 10px; }
#image-panel.hidden { display: none; }
#webcam-video {
max-width: 100%;
border-radius: 8px;
border: 1px solid #e0e0e0;
background: #111;
display: none;
}
#video-player {
max-width: 100%;
border-radius: 8px;
border: 1px solid #e0e0e0;
background: #111;
display: none;
}
.video-progress-wrap {
width: 100%;
max-width: 640px;
display: flex;
flex-direction: column;
gap: 4px;
}
#video-progress {
width: 100%;
accent-color: #1565c0;
cursor: pointer;
}
#video-progress:disabled { opacity: 0.4; cursor: default; }
.video-time {
display: flex;
justify-content: space-between;
font-size: 0.82rem;
color: #888;
padding: 0 2px;
}
.webcam-controls {
display: flex;
gap: 10px;
flex-wrap: wrap;
justify-content: center;
}
.btn-secondary {
padding: 8px 20px;
font-size: 0.9rem;
font-weight: 600;
background: #fff;
color: #1565c0;
border: 2px solid #1565c0;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary:hover:not(:disabled) { background: #e8f4fd; }
.btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-danger {
padding: 8px 20px;
font-size: 0.9rem;
font-weight: 600;
background: #fff;
color: #c62828;
border: 2px solid #c62828;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
}
.btn-danger:hover:not(:disabled) { background: #fdecea; }
/* Timing info */
#timing-bar {
display: none;
align-items: center;
gap: 16px;
background: #fff;
border-radius: 12px;
padding: 10px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
font-size: 0.88rem;
color: #555;
flex-wrap: wrap;
}
#timing-bar.visible { display: flex; }
.timing-item { display: flex; align-items: center; gap: 6px; }
.timing-label { color: #888; }
.timing-value { font-weight: 700; color: #1565c0; }
/* Canvas area */
.canvas-area {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
@media (max-width: 700px) {
.canvas-area {
grid-template-columns: 1fr;
}
}
.canvas-wrapper {
background: #fff;
border-radius: 12px;
padding: 16px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.canvas-wrapper h2 {
font-size: 1rem;
font-weight: 600;
color: #555;
}
canvas {
max-width: 100%;
border-radius: 6px;
background: #f5f5f5;
border: 1px solid #e0e0e0;
display: block;
}
/* Canvas wrapper β€” position:relative để magnifier tΓ­nh toΓ‘n offset */
.canvas-wrapper {
position: relative;
}
/* Magnifier lens */
#magnifier {
position: fixed;
width: 180px;
height: 180px;
border-radius: 50%;
border: 3px solid #1565c0;
box-shadow: 0 4px 20px rgba(0,0,0,0.35);
pointer-events: none;
display: none;
overflow: hidden;
z-index: 9999;
background: #111;
}
#magnifier canvas {
position: absolute;
top: 0;
left: 0;
border: none;
border-radius: 0;
background: transparent;
max-width: none;
}
/* Zoom control bar */
#zoom-bar {
display: flex;
align-items: center;
gap: 10px;
background: #fff;
border-radius: 12px;
padding: 12px 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
font-size: 0.9rem;
color: #555;
}
#zoom-bar label {
font-weight: 600;
white-space: nowrap;
}
#zoom-slider {
flex: 1;
max-width: 200px;
accent-color: #1565c0;
cursor: pointer;
}
#zoom-value {
font-weight: 700;
color: #1565c0;
min-width: 28px;
text-align: right;
}
/* Stats table */
#table-section {
background: #fff;
border-radius: 12px;
padding: 20px;
box-shadow: 0 1px 4px rgba(0,0,0,0.08);
display: none;
}
#table-section h2 {
font-size: 1rem;
font-weight: 600;
margin-bottom: 12px;
color: #555;
}
#detection-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
#detection-table thead tr {
background: #e3f2fd;
}
#detection-table th,
#detection-table td {
padding: 10px 14px;
text-align: left;
border-bottom: 1px solid #e0e0e0;
}
#detection-table th {
font-weight: 600;
color: #1565c0;
}
#detection-table tbody tr:hover {
background: #f5f5f5;
}
#detection-table tbody tr:last-child td {
border-bottom: none;
}
</style>
</head>
<body>
<div class="container">
<h1>YOLO Image Detection</h1>
<div id="status">Đang khởi tẑo...</div>
<div class="input-area">
<div class="model-selector">
<label for="model-select">Model:</label>
<select id="model-select" disabled></select>
</div>
<!-- Source tabs -->
<div class="source-tabs">
<button class="tab-btn active" id="tab-image">πŸ–Ό αΊ’nh</button>
<button class="tab-btn" id="tab-webcam">πŸ“· Webcam</button>
<button class="tab-btn" id="tab-video">🎬 Video</button>
</div>
<!-- Image panel -->
<div id="image-panel">
<label class="file-label" for="file-input">
πŸ“ Chọn αΊ£nh (PNG, JPG, WEBP)
</label>
<input type="file" id="file-input" accept="image/png,image/jpeg,image/webp" />
<button id="sample-btn" class="btn-sample">or try sample</button>
<button id="detect-btn" disabled>Detect</button>
</div>
<!-- Webcam panel -->
<div id="webcam-panel">
<video id="webcam-video" autoplay playsinline muted width="640" height="480"></video>
<div class="webcam-controls">
<button class="btn-secondary" id="webcam-start-btn">β–Ά BαΊ­t Webcam</button>
<button class="btn-secondary" id="webcam-detect-btn" disabled>⏯ BαΊ―t Δ‘αΊ§u nhαΊ­n diện</button>
<button class="btn-secondary" id="webcam-capture-btn" disabled>πŸ“‹ Capture β†’ Clipboard</button>
<button class="btn-danger" id="webcam-stop-btn" disabled>β–  Dα»«ng</button>
</div>
</div>
<!-- Video panel -->
<div id="video-panel">
<label class="file-label" for="video-file-input">
πŸ“ Chọn video (MP4, WebM, MOV)
</label>
<input type="file" id="video-file-input" accept="video/mp4,video/webm,video/quicktime" />
<video id="video-player" playsinline muted width="640" height="360"></video>
<div class="video-progress-wrap">
<input type="range" id="video-progress" min="0" max="100" step="0.1" value="0" disabled />
<div class="video-time">
<span id="video-current-time">0:00</span>
<span id="video-duration">0:00</span>
</div>
</div>
<div class="webcam-controls">
<button class="btn-secondary" id="video-play-btn" disabled>β–Ά Play</button>
<button class="btn-secondary" id="video-detect-btn" disabled>🎯 BαΊ―t Δ‘αΊ§u nhαΊ­n diện</button>
<button class="btn-secondary" id="video-capture-btn" disabled>πŸ“‹ Capture β†’ Clipboard</button>
<button class="btn-danger" id="video-stop-btn" disabled>β–  Reset</button>
</div>
</div>
</div>
<div class="canvas-area">
<div class="canvas-wrapper">
<h2>αΊ’nh gα»‘c</h2>
<canvas id="original-canvas" width="640" height="480"></canvas>
</div>
<div class="canvas-wrapper">
<h2>KαΊΏt quαΊ£ nhαΊ­n diện</h2>
<canvas id="result-canvas" width="640" height="480"></canvas>
</div>
</div>
<!-- Timing info -->
<div id="timing-bar">
<div class="timing-item">
<span class="timing-label">⏱ Thời gian nhαΊ­n diện:</span>
<span class="timing-value" id="timing-inference">β€”</span>
</div>
<div class="timing-item" id="fps-item" style="display:none">
<span class="timing-label">🎞 FPS:</span>
<span class="timing-value" id="timing-fps">β€”</span>
</div>
</div>
<!-- Zoom control -->
<div id="zoom-bar">
<label for="zoom-slider">πŸ” KΓ­nh lΓΊp:</label>
<input type="range" id="zoom-slider" min="1" max="5" step="0.5" value="2" />
<span id="zoom-value">Γ—2</span>
<span style="color:#bbb;margin:0 4px">|</span>
<label for="size-slider" style="white-space:nowrap">KΓ­ch thΖ°α»›c:</label>
<input type="range" id="size-slider" min="100" max="300" step="10" value="180" />
<span id="size-value">180px</span>
</div>
<!-- Magnifier lens (follows cursor) -->
<div id="magnifier">
<canvas id="magnifier-canvas" width="180" height="180"></canvas>
</div>
<div id="table-section">
<h2>Thα»‘ng kΓͺ kαΊΏt quαΊ£</h2>
<table id="detection-table">
<thead>
<tr>
<th>TΓͺn Class</th>
<th>Sα»‘ Lượng</th>
<th>Confidence Trung Bình</th>
</tr>
</thead>
<tbody id="table-body"></tbody>
</table>
</div>
</div>
<!-- ONNX Runtime Web via CDN -->
<script src="https://cdn.jsdelivr.net/npm/onnxruntime-web/dist/ort.min.js"></script>
<!-- App logic -->
<script type="module">
import {
loadModel, loadClasses, loadRegistry, createDetector, runDetection,
preprocessImage, preprocessFromCanvas,
drawDetections, drawDetectionsOnCtx, renderTable,
} from './Yolo.js';
// ── State ─────────────────────────────────────────────────────────────────
let session = null;
let classes = [];
let currentImage = null;
let registry = [];
let currentModelEntry = null;
let detector = null;
// ── UI Helpers ────────────────────────────────────────────────────────────
function setStatus(state, message) {
const el = document.getElementById('status');
el.className = state;
const defaults = { loading: 'Đang tαΊ£i...', ready: 'SαΊ΅n sΓ ng', processing: 'Đang xα»­ lΓ½...', error: 'Lα»—i' };
el.textContent = message ?? defaults[state] ?? '';
}
function clearResults() {
const canvas = document.getElementById('result-canvas');
canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height);
document.getElementById('table-section').style.display = 'none';
document.getElementById('table-body').innerHTML = '';
}
function showTiming(ms, fps = null) {
const bar = document.getElementById('timing-bar');
bar.classList.add('visible');
document.getElementById('timing-inference').textContent = ms.toFixed(1) + ' ms';
const fpsItem = document.getElementById('fps-item');
if (fps !== null) {
fpsItem.style.display = 'flex';
document.getElementById('timing-fps').textContent = fps.toFixed(1);
} else {
fpsItem.style.display = 'none';
}
}
// ── Model Loading ─────────────────────────────────────────────────────────
async function loadSelectedModel() {
const detectBtn = document.getElementById('detect-btn');
const modelSelect = document.getElementById('model-select');
const entry = registry[parseInt(modelSelect.value, 10)];
if (!entry) return;
detectBtn.disabled = true;
setStatus('loading', `Đang tải model "${entry.name}"...`);
try {
[session, classes] = await Promise.all([loadModel(entry.modelPath), loadClasses(entry.classesPath)]);
currentModelEntry = entry;
detector = createDetector(session, classes, entry);
setStatus('ready', `SαΊ΅n sΓ ng β€” ${entry.name} (${classes.length} class)`);
detectBtn.disabled = false;
} catch (err) {
console.error('Load model thαΊ₯t bαΊ‘i:', err);
setStatus('error', `Lα»—i tαΊ£i model: ${err.message}`);
detectBtn.disabled = true;
}
}
// ── Init ──────────────────────────────────────────────────────────────────
(async function init() {
const detectBtn = document.getElementById('detect-btn');
const modelSelect = document.getElementById('model-select');
detectBtn.disabled = true;
modelSelect.disabled = true;
setStatus('loading', 'Đang tải danh sÑch model...');
try {
registry = await loadRegistry();
modelSelect.innerHTML = '';
registry.forEach((m, i) => {
const opt = document.createElement('option');
opt.value = i; opt.textContent = m.name;
modelSelect.appendChild(opt);
});
modelSelect.disabled = false;
await loadSelectedModel();
} catch (err) {
console.error('Khởi tαΊ‘o thαΊ₯t bαΊ‘i:', err);
setStatus('error', `Lα»—i khởi tαΊ‘o: ${err.message}`);
detectBtn.disabled = true;
}
})();
// ── Event Handlers ────────────────────────────────────────────────────────
document.getElementById('model-select').addEventListener('change', async () => {
clearResults();
await loadSelectedModel();
});
// File input
const ACCEPTED_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
const MAX_CANVAS_SIZE = 640;
function displayImageOnCanvas(img) {
currentImage = img;
const canvas = document.getElementById('original-canvas');
let drawW = img.naturalWidth, drawH = img.naturalHeight;
if (drawW > MAX_CANVAS_SIZE || drawH > MAX_CANVAS_SIZE) {
const ratio = Math.min(MAX_CANVAS_SIZE / drawW, MAX_CANVAS_SIZE / drawH);
drawW = Math.round(drawW * ratio);
drawH = Math.round(drawH * ratio);
}
canvas.width = drawW; canvas.height = drawH;
canvas.getContext('2d').drawImage(img, 0, 0, drawW, drawH);
}
document.getElementById('file-input').addEventListener('change', function (e) {
const file = e.target.files[0];
if (!file) return;
if (!ACCEPTED_TYPES.includes(file.type)) {
setStatus('error', 'Định dαΊ‘ng khΓ΄ng hợp lệ. Chỉ chαΊ₯p nhαΊ­n PNG, JPG, WEBP.');
return;
}
clearResults();
const reader = new FileReader();
reader.onload = (ev) => {
const img = new Image();
img.onload = () => displayImageOnCanvas(img);
img.src = ev.target.result;
};
reader.readAsDataURL(file);
});
// Sample image
document.getElementById('sample-btn').addEventListener('click', async () => {
clearResults();
try {
const blob = await (await fetch('hikari.jpg')).blob();
const blobUrl = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => { displayImageOnCanvas(img); URL.revokeObjectURL(blobUrl); };
img.src = blobUrl;
} catch (err) {
setStatus('error', `KhΓ΄ng thể tαΊ£i αΊ£nh mαΊ«u: ${err.message}`);
}
});
// Detect button
document.getElementById('detect-btn').addEventListener('click', async function () {
if (!currentImage || !detector) return;
this.disabled = true;
setStatus('processing', 'Đang nhαΊ­n diện...');
try {
const t0 = performance.now();
const pre = preprocessImage(currentImage);
const detections = await runDetection(detector, currentModelEntry, pre);
const elapsed = performance.now() - t0;
drawDetections(document.getElementById('result-canvas'), currentImage, detections);
renderTable(detections);
showTiming(elapsed);
setStatus('ready', detections.length === 0 ? 'KhΓ΄ng phΓ‘t hiện Δ‘α»‘i tượng nΓ o' : `PhΓ‘t hiện ${detections.length} Δ‘α»‘i tượng`);
} catch (err) {
console.error('Lα»—i nhαΊ­n diện:', err);
setStatus('error', `Lα»—i: ${err.message}`);
} finally {
this.disabled = false;
}
});
// ── Source Tabs ───────────────────────────────────────────────────────────
document.getElementById('tab-image').addEventListener('click', () => switchTab('image'));
document.getElementById('tab-webcam').addEventListener('click', () => switchTab('webcam'));
document.getElementById('tab-video').addEventListener('click', () => switchTab('video'));
function switchTab(tab) {
['image', 'webcam', 'video'].forEach(t => {
document.getElementById(`tab-${t}`).classList.toggle('active', t === tab);
});
document.getElementById('image-panel').classList.toggle('hidden', tab !== 'image');
document.getElementById('webcam-panel').classList.toggle('active', tab === 'webcam');
document.getElementById('video-panel').classList.toggle('active', tab === 'video');
if (tab !== 'webcam') stopWebcam();
if (tab !== 'video') stopVideo();
}
// ── Webcam ────────────────────────────────────────────────────────────────
let webcamStream = null, webcamRunning = false, webcamRafId = null;
let fpsFrameCount = 0, fpsLastTime = 0, currentFps = 0;
const video = document.getElementById('webcam-video');
const startBtn = document.getElementById('webcam-start-btn');
const detectWcBtn = document.getElementById('webcam-detect-btn');
const captureBtn = document.getElementById('webcam-capture-btn');
const stopBtn = document.getElementById('webcam-stop-btn');
startBtn.addEventListener('click', startWebcam);
detectWcBtn.addEventListener('click', toggleWebcamDetection);
stopBtn.addEventListener('click', stopWebcam);
captureBtn.addEventListener('click', captureToClipboard);
async function startWebcam() {
try {
webcamStream = await navigator.mediaDevices.getUserMedia({ video: { width: 640, height: 480 } });
video.srcObject = webcamStream;
video.style.display = 'block';
startBtn.disabled = true; detectWcBtn.disabled = false; stopBtn.disabled = false;
setStatus('ready', 'Webcam Δ‘Γ£ bαΊ­t β€” nhαΊ₯n "BαΊ―t Δ‘αΊ§u nhαΊ­n diện"');
} catch (err) {
setStatus('error', `KhΓ΄ng thể truy cαΊ­p webcam: ${err.message}`);
}
}
function toggleWebcamDetection() {
if (webcamRunning) {
webcamRunning = false;
if (webcamRafId) cancelAnimationFrame(webcamRafId);
detectWcBtn.textContent = '⏯ BαΊ―t Δ‘αΊ§u nhαΊ­n diện';
captureBtn.disabled = true;
setStatus('ready', 'Đã dα»«ng nhαΊ­n diện webcam');
} else {
if (!detector) { setStatus('error', 'ChΖ°a tαΊ£i model'); return; }
webcamRunning = true; fpsFrameCount = 0; fpsLastTime = performance.now();
detectWcBtn.textContent = '⏸ Tẑm dừng'; captureBtn.disabled = false;
webcamLoop();
}
}
async function webcamLoop() {
if (!webcamRunning) return;
if (video.readyState >= 2) {
const t0 = performance.now();
const origCanvas = document.getElementById('original-canvas');
origCanvas.width = video.videoWidth || 640;
origCanvas.height = video.videoHeight || 480;
origCanvas.getContext('2d').drawImage(video, 0, 0);
const pre = preprocessFromCanvas(origCanvas);
const detections = await runDetection(detector, currentModelEntry, pre);
const elapsed = performance.now() - t0;
const resultCanvas = document.getElementById('result-canvas');
resultCanvas.width = origCanvas.width; resultCanvas.height = origCanvas.height;
const ctx = resultCanvas.getContext('2d');
ctx.drawImage(origCanvas, 0, 0);
drawDetectionsOnCtx(ctx, detections, classes.length);
renderTable(detections);
fpsFrameCount++;
const now = performance.now();
if (now - fpsLastTime >= 500) {
currentFps = fpsFrameCount / ((now - fpsLastTime) / 1000);
fpsFrameCount = 0; fpsLastTime = now;
}
showTiming(elapsed, currentFps);
}
webcamRafId = requestAnimationFrame(webcamLoop);
}
function stopWebcam() {
webcamRunning = false;
if (webcamRafId) cancelAnimationFrame(webcamRafId);
if (webcamStream) { webcamStream.getTracks().forEach(t => t.stop()); webcamStream = null; }
video.srcObject = null; video.style.display = 'none';
startBtn.disabled = false; detectWcBtn.disabled = true;
detectWcBtn.textContent = '⏯ BαΊ―t Δ‘αΊ§u nhαΊ­n diện';
captureBtn.disabled = true; stopBtn.disabled = true;
setStatus('ready', 'Webcam Δ‘Γ£ tαΊ―t');
}
async function captureToClipboard() {
const resultCanvas = document.getElementById('result-canvas');
try {
const blob = await new Promise(res => resultCanvas.toBlob(res, 'image/png'));
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
setStatus('ready', 'βœ… Đã copy αΊ£nh vΓ o clipboard');
} catch (err) {
setStatus('error', `KhΓ΄ng thể copy: ${err.message}`);
}
}
// ── Video ─────────────────────────────────────────────────────────────────
let videoObjectUrl = null, videoDetecting = false, videoRafId = null;
let videoFpsCount = 0, videoFpsLastTime = 0, videoCurrentFps = 0;
const videoPlayer = document.getElementById('video-player');
const videoProgress = document.getElementById('video-progress');
const videoCurrentEl = document.getElementById('video-current-time');
const videoDurationEl = document.getElementById('video-duration');
const videoPlayBtn = document.getElementById('video-play-btn');
const videoDetectBtn = document.getElementById('video-detect-btn');
const videoCaptureBtn = document.getElementById('video-capture-btn');
const videoStopBtn = document.getElementById('video-stop-btn');
function formatTime(s) {
const m = Math.floor(s / 60);
return `${m}:${String(Math.floor(s % 60)).padStart(2, '0')}`;
}
document.getElementById('video-file-input').addEventListener('change', function (e) {
const file = e.target.files[0];
if (!file) return;
stopVideo();
if (videoObjectUrl) URL.revokeObjectURL(videoObjectUrl);
videoObjectUrl = URL.createObjectURL(file);
videoPlayer.src = videoObjectUrl;
videoPlayer.style.display = 'block';
videoPlayer.load();
});
videoPlayer.addEventListener('loadedmetadata', () => {
videoProgress.max = videoPlayer.duration;
videoProgress.value = 0;
videoProgress.disabled = false;
videoDurationEl.textContent = formatTime(videoPlayer.duration);
videoCurrentEl.textContent = '0:00';
videoPlayBtn.disabled = false;
videoDetectBtn.disabled = false;
videoStopBtn.disabled = false;
setStatus('ready', 'Video Δ‘Γ£ tαΊ£i β€” nhαΊ₯n Play hoαΊ·c BαΊ―t Δ‘αΊ§u nhαΊ­n diện');
});
videoPlayer.addEventListener('timeupdate', () => {
if (!videoPlayer.seeking) {
videoProgress.value = videoPlayer.currentTime;
videoCurrentEl.textContent = formatTime(videoPlayer.currentTime);
}
});
videoPlayer.addEventListener('ended', () => {
videoPlayBtn.textContent = 'β–Ά Play';
if (videoDetecting) stopVideoDetection();
});
videoProgress.addEventListener('input', () => {
videoPlayer.currentTime = parseFloat(videoProgress.value);
});
videoPlayBtn.addEventListener('click', () => {
if (videoPlayer.paused) {
videoPlayer.play();
videoPlayBtn.textContent = '⏸ Pause';
} else {
videoPlayer.pause();
videoPlayBtn.textContent = 'β–Ά Play';
}
});
videoDetectBtn.addEventListener('click', () => {
if (videoDetecting) {
stopVideoDetection();
} else {
if (!detector) { setStatus('error', 'ChΖ°a tαΊ£i model'); return; }
videoDetecting = true;
videoFpsCount = 0; videoFpsLastTime = performance.now();
videoDetectBtn.textContent = '⏹ Dα»«ng nhαΊ­n diện';
videoCaptureBtn.disabled = false;
if (videoPlayer.paused) videoPlayer.play();
videoPlayBtn.textContent = '⏸ Pause';
videoDetectLoop();
}
});
async function videoDetectLoop() {
if (!videoDetecting || videoPlayer.paused || videoPlayer.ended) {
if (videoPlayer.ended) stopVideoDetection();
return;
}
const t0 = performance.now();
const origCanvas = document.getElementById('original-canvas');
origCanvas.width = videoPlayer.videoWidth || 640;
origCanvas.height = videoPlayer.videoHeight || 360;
origCanvas.getContext('2d').drawImage(videoPlayer, 0, 0);
const pre = preprocessFromCanvas(origCanvas);
const detections = await runDetection(detector, currentModelEntry, pre);
const elapsed = performance.now() - t0;
const resultCanvas = document.getElementById('result-canvas');
resultCanvas.width = origCanvas.width; resultCanvas.height = origCanvas.height;
const ctx = resultCanvas.getContext('2d');
ctx.drawImage(origCanvas, 0, 0);
drawDetectionsOnCtx(ctx, detections, classes.length);
renderTable(detections);
videoFpsCount++;
const now = performance.now();
if (now - videoFpsLastTime >= 500) {
videoCurrentFps = videoFpsCount / ((now - videoFpsLastTime) / 1000);
videoFpsCount = 0; videoFpsLastTime = now;
}
showTiming(elapsed, videoCurrentFps);
videoRafId = requestAnimationFrame(videoDetectLoop);
}
function stopVideoDetection() {
videoDetecting = false;
if (videoRafId) cancelAnimationFrame(videoRafId);
videoDetectBtn.textContent = '🎯 BαΊ―t Δ‘αΊ§u nhαΊ­n diện';
videoCaptureBtn.disabled = true;
}
function stopVideo() {
stopVideoDetection();
videoPlayer.pause();
videoPlayer.src = '';
videoPlayer.style.display = 'none';
videoProgress.value = 0;
videoProgress.disabled = true;
videoCurrentEl.textContent = '0:00';
videoDurationEl.textContent = '0:00';
videoPlayBtn.textContent = 'β–Ά Play';
videoPlayBtn.disabled = true;
videoDetectBtn.disabled = true;
videoCaptureBtn.disabled = true;
videoStopBtn.disabled = true;
}
videoCaptureBtn.addEventListener('click', async () => {
const resultCanvas = document.getElementById('result-canvas');
try {
const blob = await new Promise(res => resultCanvas.toBlob(res, 'image/png'));
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
setStatus('ready', 'βœ… Đã copy frame vΓ o clipboard');
} catch (err) {
setStatus('error', `KhΓ΄ng thể copy: ${err.message}`);
}
});
videoStopBtn.addEventListener('click', stopVideo);
// ── Magnifier ─────────────────────────────────────────────────────────────
(function initMagnifier() {
const magnifier = document.getElementById('magnifier');
const magCanvas = document.getElementById('magnifier-canvas');
const magCtx = magCanvas.getContext('2d');
const zoomSlider = document.getElementById('zoom-slider');
const zoomValueEl = document.getElementById('zoom-value');
const sizeSlider = document.getElementById('size-slider');
const sizeValueEl = document.getElementById('size-value');
let zoomLevel = parseFloat(zoomSlider.value);
let lensSize = parseInt(sizeSlider.value, 10);
function applyLensSize(size) {
magnifier.style.width = magnifier.style.height = size + 'px';
magCanvas.width = magCanvas.height = size;
}
applyLensSize(lensSize);
zoomSlider.addEventListener('input', () => {
zoomLevel = parseFloat(zoomSlider.value);
zoomValueEl.textContent = `Γ—${zoomLevel % 1 === 0 ? zoomLevel : zoomLevel.toFixed(1)}`;
});
sizeSlider.addEventListener('input', () => {
lensSize = parseInt(sizeSlider.value, 10);
sizeValueEl.textContent = lensSize + 'px';
applyLensSize(lensSize);
});
['original-canvas', 'result-canvas', 'webcam-video', 'video-player'].forEach(id => {
const el = document.getElementById(id);
el.addEventListener('mouseenter', () => { magnifier.style.display = 'block'; el.style.cursor = 'crosshair'; });
el.addEventListener('mouseleave', () => { magnifier.style.display = 'none'; el.style.cursor = ''; });
el.addEventListener('mousemove', (e) => {
const rect = el.getBoundingClientRect();
const srcX = (e.clientX - rect.left) * (el.width / rect.width);
const srcY = (e.clientY - rect.top) * (el.height / rect.height);
const srcW = lensSize / zoomLevel, srcH = lensSize / zoomLevel;
magCtx.clearRect(0, 0, lensSize, lensSize);
magCtx.drawImage(el, srcX - srcW / 2, srcY - srcH / 2, srcW, srcH, 0, 0, lensSize, lensSize);
const offset = 8;
let lx = e.clientX + offset, ly = e.clientY + offset;
if (lx + lensSize > window.innerWidth) lx = e.clientX - lensSize - offset;
if (ly + lensSize > window.innerHeight) ly = e.clientY - lensSize - offset;
magnifier.style.left = lx + 'px'; magnifier.style.top = ly + 'px';
});
});
})();
</script>
</body>
</html>