Spaces:
Paused
Paused
| document.addEventListener('DOMContentLoaded', () => { | |
| // ============================================= | |
| // Tab Navigation | |
| // ============================================= | |
| document.querySelectorAll('.nav-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active')); | |
| document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active')); | |
| btn.classList.add('active'); | |
| document.getElementById(btn.dataset.tab).classList.add('active'); | |
| }); | |
| }); | |
| document.querySelectorAll('.sub-btn').forEach(btn => { | |
| btn.addEventListener('click', () => { | |
| const parent = btn.closest('.glass-panel'); | |
| parent.querySelectorAll('.sub-btn').forEach(b => b.classList.remove('active')); | |
| parent.querySelectorAll('.sub-content').forEach(c => c.classList.remove('active')); | |
| btn.classList.add('active'); | |
| document.getElementById(btn.dataset.target).classList.add('active'); | |
| }); | |
| }); | |
| refreshData(); | |
| // ============================================= | |
| // Toast Notification | |
| // ============================================= | |
| function showToast(message, type = 'success') { | |
| const container = document.getElementById('toast-container'); | |
| const toast = document.createElement('div'); | |
| toast.className = `toast ${type}`; | |
| const icon = type === 'success' ? 'fa-check-circle' : 'fa-exclamation-triangle'; | |
| toast.innerHTML = `<i class="fa-solid ${icon}"></i> <span>${message}</span>`; | |
| container.appendChild(toast); | |
| setTimeout(() => toast.remove(), 4500); | |
| } | |
| // ============================================= | |
| // Drone Mode Toggle Logic | |
| // ============================================= | |
| const droneSwitch = document.getElementById('drone-mode-switch'); | |
| const rtspWrap = document.getElementById('rtsp-wrap'); | |
| const cameraSourceEl = document.getElementById('camera-source'); | |
| const navDroneBadge = document.getElementById('nav-drone-badge'); | |
| const navDroneLabel = document.getElementById('nav-drone-label'); | |
| const droneOverlay = document.getElementById('drone-overlay-badge'); | |
| function applyDroneUI(enabled) { | |
| if (enabled) { | |
| rtspWrap.classList.remove('hidden'); | |
| cameraSourceEl.classList.add('hidden'); | |
| navDroneBadge.classList.add('active'); | |
| navDroneLabel.textContent = 'Drone Mode'; | |
| document.body.classList.add('drone-active'); | |
| droneOverlay.classList.remove('hidden'); | |
| } else { | |
| rtspWrap.classList.add('hidden'); | |
| cameraSourceEl.classList.remove('hidden'); | |
| navDroneBadge.classList.remove('active'); | |
| navDroneLabel.textContent = 'Normal Mode'; | |
| document.body.classList.remove('drone-active'); | |
| droneOverlay.classList.add('hidden'); | |
| } | |
| } | |
| droneSwitch.addEventListener('change', () => { | |
| applyDroneUI(droneSwitch.checked); | |
| }); | |
| // ============================================= | |
| // Camera Controls | |
| // ============================================= | |
| // Populate cameras on load | |
| if (navigator.mediaDevices && navigator.mediaDevices.enumerateDevices) { | |
| navigator.mediaDevices.enumerateDevices().then(devices => { | |
| const videoDevices = devices.filter(d => d.kind === 'videoinput'); | |
| const cameraSourceEl = document.getElementById('camera-source'); | |
| if (videoDevices.length > 0) { | |
| cameraSourceEl.innerHTML = ''; | |
| videoDevices.forEach((d, i) => { | |
| const opt = document.createElement('option'); | |
| opt.value = d.deviceId; | |
| opt.textContent = d.label || `Camera ${i + 1}`; | |
| cameraSourceEl.appendChild(opt); | |
| }); | |
| } | |
| }); | |
| } | |
| const btnToggleCamera = document.getElementById('btn-toggle-camera'); | |
| const cameraModeEl = document.getElementById('camera-mode'); | |
| const videoFeed = document.getElementById('video-feed'); | |
| const videoWrapper = document.getElementById('video-wrapper'); | |
| const noSignal = document.querySelector('.no-signal'); | |
| const cameraStatusText = document.getElementById('camera-status-text'); | |
| const cameraStatusIndicator = document.getElementById('camera-status-indicator'); | |
| let isCameraActive = false; | |
| let localStream = null; | |
| let localHiddenVideo = document.createElement('video'); | |
| localHiddenVideo.autoplay = true; | |
| localHiddenVideo.playsInline= true; | |
| let localHiddenCanvas = document.createElement('canvas'); | |
| let motionPollInterval = null; | |
| let heartbeatInterval = null; | |
| let streamRetryCount = 0; | |
| const MAX_STREAM_RETRIES = 10; | |
| btnToggleCamera.addEventListener('click', () => { | |
| if (!isCameraActive) { | |
| const isDrone = droneSwitch.checked; | |
| const mode = cameraModeEl.value; | |
| const source = cameraSourceEl.value; | |
| const rtspUrl = document.getElementById('rtsp-url').value.trim(); | |
| if (isDrone && !rtspUrl) { | |
| showToast('Please enter a Stream URL (HTTP/RTSP) for Drone Mode.', 'error'); | |
| return; | |
| } | |
| const payload = { mode, drone_mode: isDrone }; | |
| if (isDrone) { | |
| payload.rtsp_url = rtspUrl; | |
| } else { | |
| payload.source = source; | |
| } | |
| fetch('/api/camera/start', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(payload) | |
| }).then(r => r.json()).then(data => { | |
| if (data.success) { | |
| isCameraActive = true; | |
| streamRetryCount = 0; | |
| btnToggleCamera.innerHTML = '<i class="fa-solid fa-stop"></i> Stop Recognition'; | |
| btnToggleCamera.classList.add('stop'); | |
| cameraModeEl.disabled = true; | |
| cameraSourceEl.disabled = true; | |
| droneSwitch.disabled = true; | |
| connectVideoStream(); | |
| const modeLabel = isDrone ? `Drone (${mode})` : `Active (${mode})`; | |
| cameraStatusText.textContent = modeLabel; | |
| cameraStatusText.classList.replace('text-danger', 'text-success'); | |
| cameraStatusIndicator.classList.add('on'); | |
| showToast(isDrone ? '🚁 Drone mode started!' : 'Camera started successfully.'); | |
| // Poll motion level and model status every 2s | |
| motionPollInterval = setInterval(pollDroneStatus, 2000); | |
| // Heartbeat to detect stream death | |
| heartbeatInterval = setInterval(checkHeartbeat, 3000); | |
| } else { | |
| showToast(data.error || 'Failed to start camera.', 'error'); | |
| } | |
| }); | |
| } else { | |
| fetch('/api/camera/stop', { method: 'POST' }).then(() => { | |
| isCameraActive = false; | |
| streamRetryCount = 0; | |
| btnToggleCamera.innerHTML = '<i class="fa-solid fa-play"></i> Start Recognition'; | |
| btnToggleCamera.classList.remove('stop'); | |
| cameraModeEl.disabled = false; | |
| cameraSourceEl.disabled = false; | |
| droneSwitch.disabled = false; | |
| if (localStream) { | |
| localStream.getTracks().forEach(t => t.stop()); | |
| localStream = null; | |
| } | |
| videoFeed.src = ''; | |
| videoFeed.classList.add('hidden'); | |
| noSignal.classList.remove('hidden'); | |
| videoWrapper.classList.remove('active'); | |
| cameraStatusText.textContent = 'Offline'; | |
| cameraStatusText.classList.replace('text-success', 'text-danger'); | |
| cameraStatusIndicator.classList.remove('on'); | |
| clearInterval(motionPollInterval); | |
| clearInterval(heartbeatInterval); | |
| document.getElementById('motion-level-val').textContent = '—'; | |
| showToast('Camera stopped.'); | |
| }); | |
| } | |
| }); | |
| // ============================================= | |
| // Drone Status Poll (motion level + model chips) | |
| // ============================================= | |
| function pollDroneStatus() { | |
| fetch('/api/drone/status').then(r => r.json()).then(data => { | |
| // Motion level | |
| const motionEl = document.getElementById('motion-level-val'); | |
| if (data.drone_mode) { | |
| const m = data.motion_level; | |
| motionEl.textContent = m.toFixed(1); | |
| const motionIcon = document.getElementById('motion-icon'); | |
| if (m > 15) { | |
| motionIcon.className = 'stat-icon red'; | |
| motionEl.className = 'text-danger'; | |
| } else if (m > 6) { | |
| motionIcon.className = 'stat-icon orange'; | |
| motionEl.className = 'text-orange'; | |
| } else { | |
| motionIcon.className = 'stat-icon green'; | |
| motionEl.className = 'text-success'; | |
| } | |
| } else { | |
| motionEl.textContent = '—'; | |
| } | |
| // Model chips | |
| updateChip('chip-opencv', data.opencv_dnn, 'OpenCV DNN'); | |
| updateChip('chip-deepsort', data.deepsort, 'DeepSORT'); | |
| updateChip('chip-deepface', data.deepface, 'DeepFace'); | |
| // YOLO variant | |
| const yoloChip = document.getElementById('chip-yolo'); | |
| if (data.drone_mode) { | |
| yoloChip.innerHTML = '<i class="fa-solid fa-circle-check"></i> YOLOv8s'; | |
| yoloChip.className = 'model-chip drone'; | |
| } else { | |
| yoloChip.innerHTML = '<i class="fa-solid fa-circle-check"></i> YOLOv8n'; | |
| yoloChip.className = 'model-chip active'; | |
| } | |
| }).catch(() => {}); | |
| } | |
| function updateChip(id, available, label) { | |
| const chip = document.getElementById(id); | |
| if (!chip) return; | |
| if (available) { | |
| chip.innerHTML = `<i class="fa-solid fa-circle-check"></i> ${label}`; | |
| chip.className = 'model-chip active'; | |
| } else { | |
| chip.innerHTML = `<i class="fa-solid fa-circle-xmark"></i> ${label}`; | |
| chip.className = 'model-chip inactive'; | |
| } | |
| } | |
| function pollNewModels() { | |
| fetch('/api/drone/status').then(r => r.json()).then(data => { | |
| updateChip('chip-osnet', data.osnet, data.osnet_name || 'OSNet'); | |
| updateChip('chip-gait', data.gait_engine, 'DeepGaitV2'); | |
| updateChip('chip-biomech', data.biomech, 'Biomech'); | |
| updateChip('chip-faiss', data.faiss, 'FAISS'); | |
| }).catch(() => {}); | |
| } | |
| setTimeout(pollNewModels, 4000); | |
| // Poll once on load to show model availability | |
| setTimeout(pollDroneStatus, 3000); | |
| // ============================================= | |
| // Stream Auto-Reconnect & Heartbeat | |
| // ============================================= | |
| function connectVideoStream() { | |
| const isDrone = droneSwitch.checked; | |
| if (isDrone) { | |
| // Drone Mode: MJPEG Stream from Backend (RTSP) | |
| videoFeed.src = `/video_feed?t=${Date.now()}`; | |
| videoFeed.classList.remove('hidden'); | |
| noSignal.classList.add('hidden'); | |
| videoWrapper.classList.add('active'); | |
| } else { | |
| // Normal Mode: Client WebRTC Webcam -> Canvas Base64 -> Backend POST | |
| const source = cameraSourceEl.value; | |
| const constraints = { | |
| video: { deviceId: source ? { exact: source } : undefined, width: { ideal: 640 }, height: { ideal: 480 } } | |
| }; | |
| navigator.mediaDevices.getUserMedia(constraints).then(stream => { | |
| localStream = stream; | |
| localHiddenVideo.srcObject = stream; | |
| localHiddenVideo.onloadedmetadata = () => { | |
| localHiddenVideo.play(); | |
| localHiddenCanvas.width = localHiddenVideo.videoWidth || 640; | |
| localHiddenCanvas.height = localHiddenVideo.videoHeight || 480; | |
| const sendFrame = () => { | |
| if (!isCameraActive || droneSwitch.checked) return; | |
| const ctx = localHiddenCanvas.getContext('2d'); | |
| ctx.drawImage(localHiddenVideo, 0, 0, localHiddenCanvas.width, localHiddenCanvas.height); | |
| const b64 = localHiddenCanvas.toDataURL('image/jpeg', 0.6); // 60% quality to save bandwidth | |
| fetch('/api/camera/process_frame', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ image: b64 }) | |
| }).then(r => r.json()).then(data => { | |
| if (data.image) { | |
| videoFeed.src = 'data:image/jpeg;base64,' + data.image; | |
| } | |
| if (isCameraActive && !droneSwitch.checked) { | |
| requestAnimationFrame(sendFrame); | |
| } | |
| }).catch(e => { | |
| console.error("Frame process error:", e); | |
| if (isCameraActive && !droneSwitch.checked) { | |
| setTimeout(sendFrame, 1000); // Retry after 1s on error | |
| } | |
| }); | |
| }; | |
| sendFrame(); // Start loop | |
| }; | |
| videoFeed.classList.remove('hidden'); | |
| noSignal.classList.add('hidden'); | |
| videoWrapper.classList.add('active'); | |
| showToast('Webcam connected successfully'); | |
| }).catch(err => { | |
| console.error(err); | |
| showToast('Webcam access denied or not found.', 'error'); | |
| // Auto-stop if we can't get webcam | |
| fetch('/api/camera/stop', { method: 'POST' }).then(() => { | |
| btnToggleCamera.click(); | |
| }); | |
| }); | |
| } | |
| } | |
| // Auto-reconnect when the MJPEG stream drops | |
| videoFeed.addEventListener('error', () => { | |
| if (!isCameraActive) return; | |
| if (streamRetryCount >= MAX_STREAM_RETRIES) { | |
| showToast('Stream lost after multiple retries. Try restarting.', 'error'); | |
| return; | |
| } | |
| streamRetryCount++; | |
| const delay = Math.min(1000 * streamRetryCount, 5000); | |
| console.log(`[Stream] Error detected, retry ${streamRetryCount}/${MAX_STREAM_RETRIES} in ${delay}ms`); | |
| setTimeout(() => { | |
| if (isCameraActive) { | |
| videoFeed.src = `/video_feed?t=${Date.now()}`; | |
| } | |
| }, delay); | |
| }); | |
| // Heartbeat: detect when backend is alive but stream img is broken | |
| function checkHeartbeat() { | |
| if (!isCameraActive) return; | |
| fetch('/api/camera/heartbeat').then(r => r.json()).then(data => { | |
| if (data.active && isCameraActive) { | |
| // Backend says camera is active. Check if our img is still loading | |
| // If the img has no naturalWidth, it likely lost the stream | |
| if (videoFeed.naturalWidth === 0 && !videoFeed.classList.contains('hidden')) { | |
| console.log('[Heartbeat] Stream appears dead, forcing reconnect'); | |
| streamRetryCount = 0; // reset retries for heartbeat-triggered reconnect | |
| videoFeed.src = `/video_feed?t=${Date.now()}`; | |
| } | |
| } else if (!data.active && isCameraActive) { | |
| // Backend stopped but frontend thinks it's still active | |
| console.log('[Heartbeat] Backend reported camera inactive'); | |
| } | |
| }).catch(() => {}); | |
| } | |
| // ============================================= | |
| // Data Refresh | |
| // ============================================= | |
| function refreshData() { | |
| fetch('/api/people').then(r => r.json()).then(data => { | |
| const list = document.getElementById('face-list'); | |
| document.getElementById('total-faces').textContent = data.length; | |
| list.innerHTML = data.length === 0 | |
| ? '<p class="text-gray text-center mt-3">No faces saved yet.</p>' | |
| : data.map(p => ` | |
| <div class="list-item"> | |
| <span class="name"><i class="fa-solid fa-user"></i> ${p.name}</span> | |
| <span class="badge">${p.count} emb</span> | |
| </div>`).join(''); | |
| }); | |
| fetch('/api/objects').then(r => r.json()).then(data => { | |
| const list = document.getElementById('obj-list'); | |
| document.getElementById('total-objects').textContent = data.length; | |
| list.innerHTML = data.length === 0 | |
| ? '<p class="text-gray text-center mt-3">No objects saved yet.</p>' | |
| : data.map(o => ` | |
| <div class="list-item"> | |
| <span class="name"><i class="fa-solid fa-cube"></i> ${o.name}</span> | |
| <span class="badge">${o.count} emb</span> | |
| </div>`).join(''); | |
| }); | |
| } | |
| // ============================================= | |
| // Match Log Refresh | |
| // ============================================= | |
| function refreshMatchLog() { | |
| fetch('/api/match/log').then(r => r.json()).then(events => { | |
| const list = document.getElementById('match-log-list'); | |
| if (!events || events.length === 0) { | |
| list.innerHTML = '<p class="text-gray text-center mt-2">No match events yet.</p>'; | |
| return; | |
| } | |
| list.innerHTML = events.reverse().map(ev => { | |
| const time = new Date(ev.timestamp * 1000).toLocaleTimeString(); | |
| const pct = (ev.score * 100).toFixed(1); | |
| const sc = ev.scores || {}; | |
| return ` | |
| <div class="list-item match-event"> | |
| <span class="name"><i class="fa-solid fa-crosshairs" style="color:var(--success)"></i> ${ev.name}</span> | |
| <span class="badge" style="color:var(--success)">${pct}%</span> | |
| <span class="text-small text-gray" style="width:100%;margin-top:3px"> | |
| ${time} — G:${((sc.gait||0)*100).toFixed(0)}% B:${((sc.biomech||0)*100).toFixed(0)}% A:${((sc.appearance||0)*100).toFixed(0)}% H:${((sc.height||0)*100).toFixed(0)}% | |
| ${sc.face != null ? 'F:'+((sc.face||0)*100).toFixed(0)+'%' : ''} | |
| </span> | |
| </div>`; | |
| }).join(''); | |
| }).catch(() => {}); | |
| } | |
| setInterval(refreshMatchLog, 5000); | |
| // ============================================= | |
| // Camera Calibration | |
| // ============================================= | |
| document.getElementById('btn-save-calibration').addEventListener('click', () => { | |
| const focal = parseFloat(document.getElementById('calib-focal').value) || 0; | |
| const camH = parseFloat(document.getElementById('calib-height').value) || 0; | |
| const tilt = parseFloat(document.getElementById('calib-tilt').value) || 0; | |
| fetch('/api/camera/calibration', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ focal_length_px: focal, camera_height_m: camH, camera_tilt_deg: tilt }) | |
| }).then(r => r.json()).then(data => { | |
| const badge = document.getElementById('calibration-badge'); | |
| if (data.calibrated) { | |
| badge.textContent = 'CALIBRATED'; | |
| badge.classList.add('calibrated'); | |
| showToast('Camera calibration saved! Height estimation is now active.'); | |
| } else { | |
| badge.textContent = 'NOT SET'; | |
| badge.classList.remove('calibrated'); | |
| showToast('Calibration cleared. Using relative height ratios.', 'error'); | |
| } | |
| }); | |
| }); | |
| // Load calibration on startup | |
| fetch('/api/camera/calibration').then(r => r.json()).then(data => { | |
| const badge = document.getElementById('calibration-badge'); | |
| if (data.calibrated && data.calibration) { | |
| document.getElementById('calib-focal').value = data.calibration.focal_length_px || ''; | |
| document.getElementById('calib-height').value = data.calibration.camera_height_m || ''; | |
| document.getElementById('calib-tilt').value = data.calibration.camera_tilt_deg || ''; | |
| badge.textContent = 'CALIBRATED'; | |
| badge.classList.add('calibrated'); | |
| } | |
| }).catch(() => {}); | |
| // ============================================= | |
| // File Upload | |
| // ============================================= | |
| function setupUpload(dropAreaId, fileInputId, countId) { | |
| const area = document.getElementById(dropAreaId); | |
| const input = document.getElementById(fileInputId); | |
| const count = document.getElementById(countId); | |
| area.addEventListener('click', () => input.click()); | |
| area.addEventListener('dragover', e => { e.preventDefault(); area.classList.add('dragover'); }); | |
| area.addEventListener('dragleave', () => area.classList.remove('dragover')); | |
| area.addEventListener('drop', e => { | |
| e.preventDefault(); area.classList.remove('dragover'); | |
| if (e.dataTransfer.files.length > 0) { | |
| input.files = e.dataTransfer.files; | |
| count.textContent = `${input.files.length} file(s) selected`; | |
| } | |
| }); | |
| input.addEventListener('change', () => { | |
| count.textContent = `${input.files.length} file(s) selected`; | |
| }); | |
| } | |
| setupUpload('face-drop-area', 'face-files', 'face-file-count'); | |
| setupUpload('obj-drop-area', 'obj-files', 'obj-file-count'); | |
| document.getElementById('form-add-face').addEventListener('submit', e => { | |
| e.preventDefault(); | |
| const name = document.getElementById('face-name').value.trim(); | |
| const files = document.getElementById('face-files').files; | |
| if (!name || !files.length) return showToast('Provide a name and at least 1 image.', 'error'); | |
| const fd = new FormData(); | |
| fd.append('name', name); | |
| [...files].forEach(f => fd.append('files', f)); | |
| fetch('/api/upload/person', { method: 'POST', body: fd }).then(r => r.json()).then(data => { | |
| if (data.success) { | |
| showToast(`Saved ${name}! (${data.embeddings} embeddings)`); | |
| document.getElementById('form-add-face').reset(); | |
| document.getElementById('face-file-count').textContent = 'No files selected'; | |
| refreshData(); | |
| } else showToast(data.error, 'error'); | |
| }); | |
| }); | |
| document.getElementById('form-add-obj').addEventListener('submit', e => { | |
| e.preventDefault(); | |
| const name = document.getElementById('obj-name').value.trim(); | |
| const files = document.getElementById('obj-files').files; | |
| if (!name || !files.length) return showToast('Provide a name and at least 1 image.', 'error'); | |
| const fd = new FormData(); | |
| fd.append('name', name); | |
| [...files].forEach(f => fd.append('files', f)); | |
| fetch('/api/upload/object', { method: 'POST', body: fd }).then(r => r.json()).then(data => { | |
| if (data.success) { | |
| showToast(`Saved ${name}! (${data.embeddings} embeddings)`); | |
| document.getElementById('form-add-obj').reset(); | |
| document.getElementById('obj-file-count').textContent = 'No files selected'; | |
| refreshData(); | |
| } else showToast(data.error, 'error'); | |
| }); | |
| }); | |
| // ============================================= | |
| // Delete | |
| // ============================================= | |
| document.getElementById('btn-del-face').addEventListener('click', () => { | |
| const name = document.getElementById('del-face-name').value.trim(); | |
| if (!name) return showToast('Enter a name to delete', 'error'); | |
| fetch(`/api/delete/person/${name}`, { method: 'DELETE' }).then(r => r.json()).then(data => { | |
| if (data.success) { showToast(`Deleted: ${name}`); document.getElementById('del-face-name').value = ''; refreshData(); } | |
| else showToast(data.error, 'error'); | |
| }); | |
| }); | |
| document.getElementById('btn-del-obj').addEventListener('click', () => { | |
| const name = document.getElementById('del-obj-name').value.trim(); | |
| if (!name) return showToast('Enter an object to delete', 'error'); | |
| fetch(`/api/delete/object/${name}`, { method: 'DELETE' }).then(r => r.json()).then(data => { | |
| if (data.success) { showToast(`Deleted: ${name}`); document.getElementById('del-obj-name').value = ''; refreshData(); } | |
| else showToast(data.error, 'error'); | |
| }); | |
| }); | |
| // ============================================= | |
| // Live Training (Fully Automatic — No Manual Capture) | |
| // ============================================= | |
| const overlay = document.getElementById('training-overlay'); | |
| const trainStatus = document.getElementById('training-status'); | |
| const trainFeed = document.getElementById('train-video-feed'); | |
| const trainNoSignal = document.getElementById('train-no-signal'); | |
| const trainDots = document.getElementById('train-progress-dots'); | |
| const btnCancel = document.getElementById('btn-train-cancel'); | |
| const subjectBadge = document.getElementById('train-subject-name'); | |
| let trainPollInterval = null; | |
| function openTrainingUI(name) { | |
| overlay.classList.remove('hidden'); | |
| subjectBadge.textContent = name; | |
| trainNoSignal.classList.remove('hidden'); | |
| trainFeed.classList.add('hidden'); | |
| // Reset dots | |
| trainDots.querySelectorAll('.dot').forEach(d => { | |
| d.classList.remove('captured', 'active'); | |
| }); | |
| // Connect MJPEG stream after a small delay (camera needs to init) | |
| setTimeout(() => { | |
| trainFeed.src = `/train_feed?t=${Date.now()}`; | |
| trainFeed.onload = () => { | |
| trainNoSignal.classList.add('hidden'); | |
| trainFeed.classList.remove('hidden'); | |
| }; | |
| }, 600); | |
| // Start polling status | |
| trainPollInterval = setInterval(pollTrainStatus, 800); | |
| } | |
| function closeTrainingUI() { | |
| overlay.classList.add('hidden'); | |
| trainFeed.src = ''; | |
| trainFeed.classList.add('hidden'); | |
| trainNoSignal.classList.remove('hidden'); | |
| if (trainPollInterval) clearInterval(trainPollInterval); | |
| trainPollInterval = null; | |
| } | |
| function pollTrainStatus() { | |
| fetch('/api/train/live/status').then(r => r.json()).then(data => { | |
| trainStatus.textContent = data.message; | |
| // Update progress dots | |
| const captured = data.captured || 0; | |
| const maxP = data.max || 6; | |
| trainDots.querySelectorAll('.dot').forEach((d, i) => { | |
| d.classList.remove('captured', 'active'); | |
| if (i < captured) d.classList.add('captured'); | |
| else if (i === captured) d.classList.add('active'); | |
| }); | |
| // If session ended (backend auto-finishes after 6 photos) | |
| if (data.status === 'idle' && !data.active) { | |
| closeTrainingUI(); | |
| if (data.success) { | |
| showToast(`Training done! Saved ${data.saved} images.`); | |
| refreshData(); | |
| } else if (data.message && data.message !== 'Cancelled') { | |
| showToast(data.message, 'error'); | |
| } | |
| } | |
| }).catch(() => {}); | |
| } | |
| // Cancel button — only manual interaction needed | |
| btnCancel.addEventListener('click', () => { | |
| fetch('/api/train/live/stop', { method: 'POST' }).then(() => { | |
| closeTrainingUI(); | |
| showToast('Training cancelled.'); | |
| }); | |
| }); | |
| // Right-click on video feed to select a person (multi-person scenario) | |
| trainFeed.addEventListener('contextmenu', (e) => { | |
| e.preventDefault(); | |
| // Calculate click position as percentage of the displayed image | |
| const rect = trainFeed.getBoundingClientRect(); | |
| const imgW = trainFeed.naturalWidth || 1280; | |
| const imgH = trainFeed.naturalHeight || 720; | |
| const dispW = rect.width; | |
| const dispH = rect.height; | |
| // Account for object-fit: contain scaling | |
| const scale = Math.min(dispW / imgW, dispH / imgH); | |
| const renderedW = imgW * scale; | |
| const renderedH = imgH * scale; | |
| const offsetX = (dispW - renderedW) / 2; | |
| const offsetY = (dispH - renderedH) / 2; | |
| const relX = e.clientX - rect.left - offsetX; | |
| const relY = e.clientY - rect.top - offsetY; | |
| const x_pct = Math.max(0, Math.min(1, relX / renderedW)); | |
| const y_pct = Math.max(0, Math.min(1, relY / renderedH)); | |
| fetch('/api/train/live/select', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ x_pct, y_pct }) | |
| }).then(r => r.json()).then(data => { | |
| if (data.success) { | |
| showToast('Target selected! Auto-capturing...'); | |
| } else { | |
| showToast(data.error || 'Selection failed', 'error'); | |
| } | |
| }); | |
| }); | |
| // Start training for Face | |
| document.getElementById('btn-face-live').addEventListener('click', () => { | |
| const name = document.getElementById('face-live-name').value.trim(); | |
| if (!name) return showToast('Enter a person name first', 'error'); | |
| if (isCameraActive) return showToast('Stop recognition before live training.', 'error'); | |
| fetch('/api/train/live/start', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ name, type: 'person', source: cameraSourceEl.value }) | |
| }).then(r => r.json()).then(data => { | |
| if (data.success) { | |
| document.getElementById('face-live-name').value = ''; | |
| openTrainingUI(name); | |
| } else showToast(data.error, 'error'); | |
| }); | |
| }); | |
| // Start training for Object | |
| document.getElementById('btn-obj-live').addEventListener('click', () => { | |
| const name = document.getElementById('obj-live-name').value.trim(); | |
| if (!name) return showToast('Enter an object name first', 'error'); | |
| if (isCameraActive) return showToast('Stop recognition before live training.', 'error'); | |
| fetch('/api/train/live/start', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ name, type: 'object', source: cameraSourceEl.value }) | |
| }).then(r => r.json()).then(data => { | |
| if (data.success) { | |
| document.getElementById('obj-live-name').value = ''; | |
| openTrainingUI(name); | |
| } else showToast(data.error, 'error'); | |
| }); | |
| }); | |
| // ============================================= | |
| // Profiles Tab | |
| // ============================================= | |
| // Profile video upload area (custom handler for single video file) | |
| const profDropArea = document.getElementById('profile-drop-area'); | |
| const profFileInput = document.getElementById('profile-video'); | |
| const profFileName = document.getElementById('profile-file-name'); | |
| profDropArea.addEventListener('click', () => profFileInput.click()); | |
| profDropArea.addEventListener('dragover', e => { e.preventDefault(); profDropArea.classList.add('dragover'); }); | |
| profDropArea.addEventListener('dragleave', () => profDropArea.classList.remove('dragover')); | |
| profDropArea.addEventListener('drop', e => { | |
| e.preventDefault(); profDropArea.classList.remove('dragover'); | |
| if (e.dataTransfer.files.length > 0) { | |
| profFileInput.files = e.dataTransfer.files; | |
| profFileName.textContent = `📹 ${e.dataTransfer.files[0].name}`; | |
| } | |
| }); | |
| profFileInput.addEventListener('change', () => { | |
| if (profFileInput.files.length > 0) { | |
| profFileName.textContent = `📹 ${profFileInput.files[0].name}`; | |
| } | |
| }); | |
| let activeSearchProfileId = null; | |
| let targetPollInterval = null; | |
| let targetFoundModalShown = false; | |
| let verifyPollInterval = null; | |
| function showTargetFoundPopup(name, score) { | |
| if (targetFoundModalShown) return; | |
| targetFoundModalShown = true; | |
| const overlay = document.createElement('div'); | |
| overlay.className = 'target-found-overlay'; | |
| const pctStr = score ? `${(score * 100).toFixed(0)}%` : 'HIGH'; | |
| overlay.innerHTML = ` | |
| <div class="target-found-modal"> | |
| <i class="fa-solid fa-bullseye target-icon pulse"></i> | |
| <h2>TARGET VERIFIED</h2> | |
| <p style="font-size:0.9rem; color: var(--text-muted); margin-top:8px">Multi-modal verification complete</p> | |
| <p style="margin-top:12px">Target <strong>${name}</strong> has been positively identified and verified through temporal analysis at <strong>${pctStr}</strong> confidence.</p> | |
| <button class="btn btn-primary mt-4" style="width: 100%; justify-content: center" onclick="this.closest('.target-found-overlay').remove(); setTimeout(() => targetFoundModalShown = false, 8000);">Dismiss</button> | |
| </div> | |
| `; | |
| document.body.appendChild(overlay); | |
| showToast('\u{1F3AF} Target verified and locked!', 'success'); | |
| } | |
| function startTargetPolling() { | |
| if (targetPollInterval) clearInterval(targetPollInterval); | |
| if (verifyPollInterval) clearInterval(verifyPollInterval); | |
| targetFoundModalShown = false; | |
| // Poll verification status | |
| verifyPollInterval = setInterval(() => { | |
| if (!activeSearchProfileId) { | |
| clearInterval(verifyPollInterval); | |
| return; | |
| } | |
| fetch('/api/target/verify_status').then(r => r.json()).then(data => { | |
| if (!data.active) return; | |
| const bar = document.getElementById('verify-status-' + activeSearchProfileId); | |
| if (!bar) return; | |
| const state = data.state || 'scanning'; | |
| const msg = data.message || ''; | |
| const progress = data.progress || 0; | |
| const score = data.score || 0; | |
| const ls = data.live_scores || {}; | |
| let label; | |
| if (state === 'scanning') { | |
| bar.style.color = 'var(--warning)'; | |
| bar.style.borderColor = 'var(--warning)'; | |
| label = '<i class="fa-solid fa-magnifying-glass fa-pulse"></i> ' + msg; | |
| } else if (state === 'verifying') { | |
| bar.style.color = 'var(--cyan)'; | |
| bar.style.borderColor = 'var(--cyan)'; | |
| const pct = (progress * 100).toFixed(0); | |
| label = '<i class="fa-solid fa-shield-halved fa-beat-fade"></i> ' + msg; | |
| label += '<div class="verify-progress-bar"><div class="verify-progress-fill" style="width:' + pct + '%"></div></div>'; | |
| // Per-modality mini-scores | |
| label += '<div class="verify-scores-mini">'; | |
| label += `<span title="Gait">G:${((ls.gait||0)*100).toFixed(0)}%</span>`; | |
| label += `<span title="Biomech">B:${((ls.biomech||0)*100).toFixed(0)}%</span>`; | |
| label += `<span title="Appearance">A:${((ls.appearance||0)*100).toFixed(0)}%</span>`; | |
| label += `<span title="Height">H:${((ls.height||0)*100).toFixed(0)}%</span>`; | |
| if (ls.face != null) label += `<span title="Face">F:${((ls.face||0)*100).toFixed(0)}%</span>`; | |
| label += '</div>'; | |
| } else if (state === 'confirmed') { | |
| bar.style.color = 'var(--success)'; | |
| bar.style.borderColor = 'var(--success)'; | |
| const scorePct = (score * 100).toFixed(0); | |
| label = '<i class="fa-solid fa-crosshairs"></i> TARGET LOCKED \u2014 ' + scorePct + '% confidence'; | |
| // Per-modality breakdown | |
| label += '<div class="verify-scores-mini confirmed">'; | |
| label += `<span>G:${((ls.gait||0)*100).toFixed(0)}%</span>`; | |
| label += `<span>B:${((ls.biomech||0)*100).toFixed(0)}%</span>`; | |
| label += `<span>A:${((ls.appearance||0)*100).toFixed(0)}%</span>`; | |
| label += `<span>H:${((ls.height||0)*100).toFixed(0)}%</span>`; | |
| if (ls.face != null) label += `<span>F:${((ls.face||0)*100).toFixed(0)}%</span>`; | |
| label += `<span style="color:var(--cyan)">E:${((ls.ensemble||0)*100).toFixed(0)}%</span>`; | |
| label += '</div>'; | |
| if (!targetFoundModalShown) showTargetFoundPopup(data.target_name, score); | |
| } else if (state === 'lost') { | |
| bar.style.color = 'var(--danger)'; | |
| bar.style.borderColor = 'var(--danger)'; | |
| label = '<i class="fa-solid fa-triangle-exclamation fa-beat"></i> ' + msg; | |
| } else { | |
| bar.style.color = 'var(--text-muted)'; | |
| bar.style.borderColor = 'var(--text-muted)'; | |
| label = msg; | |
| } | |
| bar.innerHTML = label; | |
| }).catch(() => {}); | |
| }, 800); | |
| // Backup poll for /api/target/found | |
| targetPollInterval = setInterval(() => { | |
| if (!activeSearchProfileId) { | |
| clearInterval(targetPollInterval); | |
| return; | |
| } | |
| fetch('/api/target/found').then(r => r.json()).then(data => { | |
| if (data.found && !targetFoundModalShown) { | |
| showTargetFoundPopup(data.name, null); | |
| } | |
| }).catch(() => {}); | |
| }, 2000); | |
| } | |
| function refreshProfiles() { | |
| fetch('/api/profiles').then(r => r.json()).then(data => { | |
| const list = document.getElementById('profiles-list'); | |
| if (data.length === 0) { | |
| list.innerHTML = '<p class="text-gray text-center mt-3">No profiles created yet.</p>'; | |
| return; | |
| } | |
| list.innerHTML = data.map(p => { | |
| let colorHtml = ''; | |
| if (p.clothing_colors && p.clothing_colors.length > 0) { | |
| const dots = p.clothing_colors.map(c => { | |
| const h = c.h * 2; | |
| let s = c.s / 255.0; | |
| let v = c.v / 255.0; | |
| let l = v * (1 - s / 2); | |
| let sl = (l === 0 || l === 1) ? 0 : (v - l) / Math.min(l, 1 - l); | |
| return `<div class="color-dot" style="background-color: hsl(${h}, ${sl*100}%, ${l*100}%)" title="Weight: ${(c.weight*100).toFixed(0)}%"></div>`; | |
| }).join(''); | |
| colorHtml = `<div class="color-dots">${dots}</div>`; | |
| } | |
| // Height display | |
| let heightLabel, clampedHeight; | |
| if (p.height_cm) { | |
| clampedHeight = Math.round(p.height_cm); | |
| heightLabel = `${clampedHeight} cm`; | |
| } else { | |
| const baseHeight = 170; | |
| const heightCm = Math.round(baseHeight + (p.height_ratio - 0.80) * 400); | |
| clampedHeight = Math.max(140, Math.min(210, heightCm)); | |
| heightLabel = `~${clampedHeight} cm`; | |
| } | |
| const heightBarPct = Math.round(((clampedHeight - 140) / 70) * 100); | |
| const heightTag = clampedHeight >= 180 ? "Tall" : (clampedHeight <= 162 ? "Short" : "Average"); | |
| // Gait info | |
| const gaitCount = p.gait_silhouette_count || 0; | |
| const hasGait = gaitCount >= 30; | |
| const gaitColor = hasGait ? 'var(--success)' : 'var(--text-muted)'; | |
| const gaitLabel = hasGait ? `${gaitCount} frames` : (gaitCount > 0 ? `${gaitCount} (need 30+)` : 'None'); | |
| // Modality checklist | |
| const hasBiomech = !!p.biomech_file; | |
| const hasAppearance = !!p.appearance_file; | |
| const hasFace = p.face_count > 0; | |
| const hasGaitV2 = !!p.gait_v2_file; | |
| const isActive = activeSearchProfileId === p.id; | |
| const btnClass = isActive ? "btn-danger-small" : "purple-btn"; | |
| const btnIcon = isActive ? "fa-stop" : "fa-crosshairs"; | |
| const btnText = isActive ? "Stop Search" : "Search Target"; | |
| const cardClass = isActive ? "profile-card active-search" : "profile-card"; | |
| return ` | |
| <div class="${cardClass}" id="profile-${p.id}"> | |
| <div class="profile-header"> | |
| <h3><i class="fa-solid fa-user-secret"></i> ${p.name}</h3> | |
| <button class="btn btn-danger-small" onclick="deleteProfile('${p.id}')"><i class="fa-solid fa-trash"></i></button> | |
| </div> | |
| <!-- 6-Modality Signal Grid --> | |
| <div class="modality-grid"> | |
| <div class="modality-chip ${hasFace ? 'mod-active' : 'mod-inactive'}"> | |
| <i class="fa-solid fa-face-smile"></i> | |
| <span>${hasFace ? p.face_count + ' faces' : 'No face'}</span> | |
| </div> | |
| <div class="modality-chip ${hasGaitV2 ? 'mod-active' : 'mod-inactive'}"> | |
| <i class="fa-solid fa-person-walking"></i> | |
| <span>${hasGait ? gaitLabel : 'No gait'}</span> | |
| </div> | |
| <div class="modality-chip ${hasBiomech ? 'mod-active' : 'mod-inactive'}"> | |
| <i class="fa-solid fa-bone"></i> | |
| <span>${hasBiomech ? '64-d vector' : 'None'}</span> | |
| </div> | |
| <div class="modality-chip ${hasAppearance ? 'mod-active' : 'mod-inactive'}"> | |
| <i class="fa-solid fa-shirt"></i> | |
| <span>${hasAppearance ? '512-d OSNet' : 'None'}</span> | |
| </div> | |
| <div class="modality-chip mod-active"> | |
| <i class="fa-solid fa-ruler-vertical"></i> | |
| <span>${heightLabel} (${heightTag})</span> | |
| </div> | |
| <div class="modality-chip ${p.clothing_colors && p.clothing_colors.length ? 'mod-active' : 'mod-inactive'}"> | |
| <i class="fa-solid fa-palette"></i> | |
| <span>Clothing</span> | |
| ${colorHtml} | |
| </div> | |
| </div> | |
| <div class="height-bar-bg mt-2"><div class="height-bar-fill" style="width:${heightBarPct}%"></div></div> | |
| <button class="btn ${btnClass} search-btn mt-3" onclick="toggleSearch('${p.id}')"> | |
| <i class="fa-solid ${btnIcon}"></i> ${btnText} | |
| </button> | |
| ${isActive ? '<div class="verify-status-bar" id="verify-status-' + p.id + '"><i class="fa-solid fa-spinner fa-spin"></i> Initializing search...</div>' : ''} | |
| </div> | |
| `; | |
| }).join(''); | |
| }); | |
| } | |
| // Call it initially | |
| refreshProfiles(); | |
| document.getElementById('form-add-profile').addEventListener('submit', e => { | |
| e.preventDefault(); | |
| const name = document.getElementById('profile-name').value.trim(); | |
| const fileInput = document.getElementById('profile-video'); | |
| if (!name || !fileInput.files.length) return showToast('Provide a name and a video.', 'error'); | |
| const fd = new FormData(); | |
| fd.append('name', name); | |
| fd.append('video', fileInput.files[0]); | |
| const submitBtn = document.getElementById('btn-submit-profile'); | |
| const progContainer = document.getElementById('profile-progress-container'); | |
| const progFill = document.getElementById('profile-progress-fill'); | |
| const progMsg = document.getElementById('profile-status-msg'); | |
| const progPct = document.getElementById('profile-status-pct'); | |
| submitBtn.disabled = true; | |
| progContainer.classList.remove('hidden'); | |
| fetch('/api/profile/upload', { method: 'POST', body: fd }) | |
| .then(r => r.json()) | |
| .then(data => { | |
| if (data.success) { | |
| showToast('Video uploaded. Analysis started...'); | |
| pollProfileStatus(data.job_id); | |
| } else { | |
| showToast(data.error, 'error'); | |
| submitBtn.disabled = false; | |
| progContainer.classList.add('hidden'); | |
| } | |
| }).catch(e => { | |
| showToast("Upload failed", "error"); | |
| submitBtn.disabled = false; | |
| progContainer.classList.add('hidden'); | |
| }); | |
| function pollProfileStatus(jobId) { | |
| const iv = setInterval(() => { | |
| fetch(`/api/profile/status/${jobId}`) | |
| .then(r => r.json()) | |
| .then(st => { | |
| if (st.status === 'error') { | |
| clearInterval(iv); | |
| showToast(st.message, 'error'); | |
| resetUploadUI(); | |
| } else if (st.status === 'not_found') { | |
| clearInterval(iv); | |
| showToast('Job lost', 'error'); | |
| resetUploadUI(); | |
| } else { | |
| progFill.style.width = st.progress + '%'; | |
| progPct.textContent = st.progress + '%'; | |
| progMsg.textContent = st.message; | |
| if (st.status === 'done') { | |
| clearInterval(iv); | |
| showToast('Profile built successfully!'); | |
| setTimeout(() => { | |
| resetUploadUI(); | |
| document.getElementById('form-add-profile').reset(); | |
| document.getElementById('profile-file-name').textContent = 'No video selected'; | |
| refreshProfiles(); | |
| }, 1000); | |
| } | |
| } | |
| }).catch(() => { clearInterval(iv); resetUploadUI(); }); | |
| }, 1000); | |
| } | |
| function resetUploadUI() { | |
| submitBtn.disabled = false; | |
| progContainer.classList.add('hidden'); | |
| progFill.style.width = '0%'; | |
| progPct.textContent = '0%'; | |
| } | |
| }); | |
| window.deleteProfile = function(id) { | |
| if (!confirm('Delete this profile?')) return; | |
| fetch(`/api/delete/profile/${id}`, { method: 'DELETE' }).then(r => r.json()).then(data => { | |
| if (data.success) { | |
| showToast('Profile deleted'); | |
| if (activeSearchProfileId === id) activeSearchProfileId = null; | |
| refreshProfiles(); | |
| } else { | |
| showToast(data.error, 'error'); | |
| } | |
| }); | |
| }; | |
| window.toggleSearch = function(id) { | |
| if (!isCameraActive) { | |
| showToast('Start recognition first to enable search mode!', 'error'); | |
| return; | |
| } | |
| if (activeSearchProfileId === id) { | |
| // Stop search | |
| fetch('/api/profile/search', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ action: 'stop' }) | |
| }).then(() => { | |
| activeSearchProfileId = null; | |
| if (targetPollInterval) clearInterval(targetPollInterval); | |
| if (verifyPollInterval) clearInterval(verifyPollInterval); | |
| targetFoundModalShown = false; | |
| refreshProfiles(); | |
| showToast('Search deactivated.'); | |
| }); | |
| } else { | |
| // Start search | |
| fetch('/api/profile/search', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ action: 'start', profile_id: id }) | |
| }).then(r => r.json()).then(data => { | |
| if (data.success) { | |
| activeSearchProfileId = id; | |
| refreshProfiles(); | |
| showToast(`\uD83D\uDD0D Target search initiated: ${data.target} \u2014 Scanning...`); | |
| startTargetPolling(); | |
| // Force faces map to view | |
| document.getElementById('camera-mode').value = 'faces'; | |
| } else { | |
| showToast(data.error, 'error'); | |
| } | |
| }); | |
| } | |
| }; | |
| }); | |