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 = ` ${message}`; 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 = ' 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 = ' 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 = ' YOLOv8s'; yoloChip.className = 'model-chip drone'; } else { yoloChip.innerHTML = ' YOLOv8n'; yoloChip.className = 'model-chip active'; } }).catch(() => {}); } function updateChip(id, available, label) { const chip = document.getElementById(id); if (!chip) return; if (available) { chip.innerHTML = ` ${label}`; chip.className = 'model-chip active'; } else { chip.innerHTML = ` ${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 ? '

No faces saved yet.

' : data.map(p => `
${p.name} ${p.count} emb
`).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 ? '

No objects saved yet.

' : data.map(o => `
${o.name} ${o.count} emb
`).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 = '

No match events yet.

'; 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 `
${ev.name} ${pct}% ${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)+'%' : ''}
`; }).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 = `

TARGET VERIFIED

Multi-modal verification complete

Target ${name} has been positively identified and verified through temporal analysis at ${pctStr} confidence.

`; 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 = ' ' + msg; } else if (state === 'verifying') { bar.style.color = 'var(--cyan)'; bar.style.borderColor = 'var(--cyan)'; const pct = (progress * 100).toFixed(0); label = ' ' + msg; label += '
'; // Per-modality mini-scores label += '
'; label += `G:${((ls.gait||0)*100).toFixed(0)}%`; label += `B:${((ls.biomech||0)*100).toFixed(0)}%`; label += `A:${((ls.appearance||0)*100).toFixed(0)}%`; label += `H:${((ls.height||0)*100).toFixed(0)}%`; if (ls.face != null) label += `F:${((ls.face||0)*100).toFixed(0)}%`; label += '
'; } else if (state === 'confirmed') { bar.style.color = 'var(--success)'; bar.style.borderColor = 'var(--success)'; const scorePct = (score * 100).toFixed(0); label = ' TARGET LOCKED \u2014 ' + scorePct + '% confidence'; // Per-modality breakdown label += '
'; label += `G:${((ls.gait||0)*100).toFixed(0)}%`; label += `B:${((ls.biomech||0)*100).toFixed(0)}%`; label += `A:${((ls.appearance||0)*100).toFixed(0)}%`; label += `H:${((ls.height||0)*100).toFixed(0)}%`; if (ls.face != null) label += `F:${((ls.face||0)*100).toFixed(0)}%`; label += `E:${((ls.ensemble||0)*100).toFixed(0)}%`; label += '
'; if (!targetFoundModalShown) showTargetFoundPopup(data.target_name, score); } else if (state === 'lost') { bar.style.color = 'var(--danger)'; bar.style.borderColor = 'var(--danger)'; label = ' ' + 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 = '

No profiles created yet.

'; 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 `
`; }).join(''); colorHtml = `
${dots}
`; } // 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 `

${p.name}

${hasFace ? p.face_count + ' faces' : 'No face'}
${hasGait ? gaitLabel : 'No gait'}
${hasBiomech ? '64-d vector' : 'None'}
${hasAppearance ? '512-d OSNet' : 'None'}
${heightLabel} (${heightTag})
Clothing ${colorHtml}
${isActive ? '
Initializing search...
' : ''}
`; }).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'); } }); } }; });