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 `
${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');
}
});
}
};
});