File size: 9,803 Bytes
3a74216
 
 
ac10861
 
3a74216
 
04f722e
3a74216
ac10861
 
3a74216
 
 
 
 
 
 
 
 
ac10861
04f722e
1996a84
 
 
 
3a74216
 
 
 
 
 
ac10861
3a74216
 
 
ac10861
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4e658cb
 
 
 
 
 
 
 
 
ac10861
 
 
 
 
 
 
 
 
4e658cb
ac10861
 
 
 
 
 
 
 
 
 
 
 
3a74216
 
 
 
 
ac10861
3a74216
ac10861
04f722e
3a74216
ac10861
3a74216
 
 
 
 
8b5d569
 
04f722e
3a74216
 
ac10861
 
 
 
 
3a74216
 
 
 
 
8b5d569
3a74216
 
 
 
ac10861
3a74216
 
 
 
 
04f722e
ac10861
 
04f722e
 
 
 
 
ac10861
04f722e
 
 
 
 
 
 
 
 
 
8b5d569
3a74216
 
 
 
 
8b5d569
3a74216
 
ac10861
8b5d569
3a74216
 
 
 
 
 
 
8b5d569
3a74216
 
 
 
 
 
ac10861
3a74216
 
 
 
 
 
 
 
 
 
 
 
 
17313e4
a586513
17313e4
a586513
3a74216
 
ac10861
 
3a74216
 
 
 
ac10861
3a74216
 
 
 
 
ac10861
3a74216
 
8b5d569
17313e4
 
3a74216
 
 
 
 
ac10861
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
const video = document.getElementById('videoElement');
const canvas = document.getElementById('canvasElement');
const ctx = canvas.getContext('2d');
const overlayCanvas = document.getElementById('overlayCanvas');
const overlayCtx = overlayCanvas.getContext('2d');
const statusBadge = document.getElementById('status-badge');
const attendanceList = document.getElementById('attendance-list');
const confirmedList = document.getElementById('confirmed-list');
const terminalLogs = document.getElementById('terminal-logs');
const debugCrops = document.getElementById('debug-crops');
const cropCount = document.getElementById('crop-count');

let ws;
let isProcessing = false;
let frameCount = 0;
let lastFpsTime = Date.now();
let reconnectAttempts = 0;
let pingInterval;

let currentlyVisible = {};
let confirmedPeople = new Set(); 
const VISIBILITY_TIMEOUT_MS = 8000;

// Calculate dynamic WebSocket URL for Hugging Face Spaces
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}/ws`;

function logToTerminal(msg) {
    const div = document.createElement('div');
    const time = new Date().toISOString().split('T')[1].slice(0, 12);
    div.innerHTML = `<span class="text-slate-500">[${time}]</span> <span class="${msg.includes('ERROR') ? 'text-red-400' : 'text-slate-300'}">${msg}</span>`;
    terminalLogs.appendChild(div);
    if (terminalLogs.children.length > 20) terminalLogs.removeChild(terminalLogs.firstChild);
    terminalLogs.scrollTop = terminalLogs.scrollHeight;
}

function drawFaceBoxes(faces) {
    overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
    if (!faces) return;

    faces.forEach(face => {
        if (face.box) {
            let { x, y, w, h } = face.box;
            // Mirror math for canvas
            const mirroredX = overlayCanvas.width - x - w;
            const color = face.status === 'match' ? '#10b981' : (face.status === 'fail' ? '#f59e0b' : '#ef4444');
            
            overlayCtx.strokeStyle = color;
            overlayCtx.lineWidth = 3;
            overlayCtx.strokeRect(mirroredX, y, w, h);
            
            overlayCtx.fillStyle = color;
            overlayCtx.font = 'bold 12px monospace';
            const label = `${face.name} (${face.score}%)`;
            overlayCtx.fillText(label, mirroredX, y > 20 ? y - 10 : y + 20);
        }
    });
}

function updateDebugCrops(faces) {
    // Always clear previous crops
    debugCrops.innerHTML = '';
    
    if (!faces || faces.length === 0) {
        debugCrops.innerHTML = '<div class="text-xs text-slate-600 italic">Waiting for detection...</div>';
        cropCount.textContent = '0 faces';
        return;
    }

    cropCount.textContent = `${faces.length} faces`;

    faces.forEach(face => {
        if (face.crop) {
            const container = document.createElement('div');
            container.className = 'relative flex-shrink-0';
            
            const img = document.createElement('img');
            img.src = face.crop;
            img.className = `w-16 h-16 rounded border-2 ${face.status === 'match' ? 'border-emerald-500' : (face.status === 'fail' ? 'border-amber-500' : 'border-slate-700')} object-cover`;
            
            const badge = document.createElement('div');
            badge.className = 'absolute bottom-0 left-0 right-0 bg-black/70 text-[8px] text-center py-0.5 truncate';
            badge.textContent = face.status === 'match' ? 'MATCH' : (face.score > 0 ? `${face.score}%` : '???');
            
            container.appendChild(img);
            container.appendChild(badge);
            debugCrops.appendChild(container);
        }
    });
}

function connectWebSocket() {
    ws = new WebSocket(wsUrl);
    ws.onopen = () => {
        reconnectAttempts = 0;
        statusBadge.textContent = '🟢 System Active';
        statusBadge.className = 'px-4 py-2 rounded-full text-sm font-semibold bg-emerald-500/20 text-emerald-400 border border-emerald-500/50';
        document.getElementById('ws-status').textContent = 'Connected';
        logToTerminal("WebSocket Connected. Running Visual Debugger...");
        attendanceList.innerHTML = '<div class="text-center text-slate-500 mt-10 text-sm">Camera is empty.</div>';
        currentlyVisible = {};
        pingInterval = setInterval(() => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'heartbeat' })); }, 10000);
    };

    ws.onmessage = (event) => {
        const data = JSON.parse(event.data);
        if (data.type === 'attendance') {
            currentlyVisible[data.name] = Date.now();
            renderPresenceList();
            addConfirmedEntry(data.name, data.time);
        } else if (data.type === 'ready') {
            isProcessing = false; 
            if(data.debug) logToTerminal(data.debug);
            if(data.faces) {
                drawFaceBoxes(data.faces);
                updateDebugCrops(data.faces);
            }
        }
    };

    ws.onclose = () => {
        isProcessing = false;
        clearInterval(pingInterval);
        statusBadge.textContent = '🔴 Disconnected';
        statusBadge.className = 'px-4 py-2 rounded-full text-sm font-semibold bg-red-500/20 text-red-400 border border-red-500/50';
        document.getElementById('ws-status').textContent = 'Offline';
        const timeout = Math.min(1000 * Math.pow(2, reconnectAttempts), 10000);
        logToTerminal(`ERROR: Lost connection. Reconnect in ${timeout/1000}s...`);
        reconnectAttempts++;
        setTimeout(connectWebSocket, timeout);
    };
}

function addConfirmedEntry(name, time) {
    if (confirmedPeople.has(name)) return;
    if (confirmedPeople.size === 0) confirmedList.innerHTML = '';
    confirmedPeople.add(name);
    const div = document.createElement('div');
    div.className = 'bg-emerald-500/10 p-3 rounded-xl border border-emerald-500/20 flex justify-between items-center animate-[slideIn_0.3s_ease-out]';
    div.innerHTML = `
        <div class="flex items-center gap-3">
            <div class="w-8 h-8 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-xs font-bold">${confirmedPeople.size}</div>
            <div>
                <div class="font-semibold text-slate-200 capitalize tracking-wide">${name.replace(/_/g, ' ')}</div>
                <div class="text-[9px] text-emerald-500 uppercase font-bold tracking-wider">Confirmed Identity</div>
            </div>
        </div>
        <span class="text-[10px] text-emerald-400 font-mono bg-emerald-400/5 px-2 py-1 rounded border border-emerald-400/10">${time}</span>
    `;
    confirmedList.insertBefore(div, confirmedList.firstChild);
}

setInterval(() => {
    const now = Date.now();
    let hasChanges = false;
    for (const name in currentlyVisible) {
        if (now - currentlyVisible[name] > VISIBILITY_TIMEOUT_MS) {
            delete currentlyVisible[name];
            hasChanges = true;
        }
    }
    if (hasChanges) renderPresenceList();
}, 1000);

function renderPresenceList() {
    const names = Object.keys(currentlyVisible);
    if (names.length === 0) {
        attendanceList.innerHTML = '<div class="text-center text-slate-500 mt-10 text-sm animate-pulse">Camera is empty.</div>';
        return;
    }
    attendanceList.innerHTML = ''; 
    const timeStr = new Date().toLocaleTimeString([], {hour: '2-digit', minute:'2-digit', second:'2-digit'});
    names.forEach(name => {
        const div = document.createElement('div');
        div.className = 'bg-slate-800/80 p-3 rounded-xl border border-emerald-500/50 flex justify-between items-center animate-[slideIn_0.3s_ease-out] shadow-[0_0_10px_rgba(16,185,129,0.1)]';
        div.innerHTML = `
            <div class="flex items-center gap-3">
                <div class="w-10 h-10 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-lg shadow-inner">🟢</div>
                <div>
                    <div class="font-semibold text-slate-200 capitalize tracking-wide">${name.replace(/_/g, ' ')}</div>
                    <div class="text-[10px] text-emerald-400 uppercase font-bold tracking-wider">Present Now</div>
                </div>
            </div>
            <span class="text-xs text-emerald-400 font-mono bg-emerald-400/10 px-2 py-1 rounded border border-emerald-400/20">${timeStr}</span>
        `;
        attendanceList.appendChild(div);
    });
}

async function startCamera() {
    try {
        // Lowered to 640x480 (VGA) for ultra-fast AI processing without losing accuracy
        const stream = await navigator.mediaDevices.getUserMedia({ 
            video: { width: 640, height: 480, facingMode: "user" } 
        });
        video.srcObject = stream;
        video.onloadedmetadata = () => {
            canvas.width = video.videoWidth; canvas.height = video.videoHeight;
            overlayCanvas.width = video.videoWidth; overlayCanvas.height = video.videoHeight;
            requestAnimationFrame(processFrame);
        };
    } catch (err) {
        statusBadge.textContent = '⚠️ Camera Denied';
        logToTerminal("ERROR: Camera access denied.");
    }
}

function processFrame() {
    frameCount++;
    if (Date.now() - lastFpsTime >= 1000) { document.getElementById('fps-counter').textContent = frameCount; frameCount = 0; lastFpsTime = Date.now(); }
    if (ws && ws.readyState === WebSocket.OPEN && !isProcessing) {
        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
        isProcessing = true; 
        // Lowered to 0.7 quality to minimize network latency on Hugging Face proxy
        ws.send(JSON.stringify({ type: 'frame', image: canvas.toDataURL('image/jpeg', 0.7) }));
    }
    requestAnimationFrame(processFrame);
}

connectWebSocket();
startCamera();