Spaces:
Running
Running
| <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> | |