/** * VideoCapture — getUserMedia webcam capture with frame extraction. * Provides quality metrics (brightness, motion) for fusion confidence gating. */ export class VideoCapture { constructor(videoElement) { this.video = videoElement; this.stream = null; this.offscreen = document.createElement('canvas'); this.offCtx = this.offscreen.getContext('2d', { willReadFrequently: true }); this.prevFrame = null; this.motionScore = 0; this.brightnessScore = 0; } async start(constraints = {}) { const defaultConstraints = { video: { width: { ideal: 640 }, height: { ideal: 480 }, facingMode: 'user', frameRate: { ideal: 30 } }, audio: false }; try { this.stream = await navigator.mediaDevices.getUserMedia( Object.keys(constraints).length ? constraints : defaultConstraints ); this.video.srcObject = this.stream; await this.video.play(); this.offscreen.width = this.video.videoWidth; this.offscreen.height = this.video.videoHeight; return true; } catch (err) { console.error('[Video] Camera access failed:', err.message); return false; } } stop() { if (this.stream) { this.stream.getTracks().forEach(t => t.stop()); this.stream = null; } this.video.srcObject = null; } get isActive() { return this.stream !== null && this.video.readyState >= 2; } get width() { return this.video.videoWidth || 640; } get height() { return this.video.videoHeight || 480; } /** * Capture current frame as RGB Uint8Array + compute quality metrics. * @param {number} targetW - Target width for CNN input * @param {number} targetH - Target height for CNN input * @returns {{ rgb: Uint8Array, width: number, height: number, motion: number, brightness: number }} */ captureFrame(targetW = 56, targetH = 56) { if (!this.isActive) return null; // Draw to offscreen at target resolution this.offscreen.width = targetW; this.offscreen.height = targetH; this.offCtx.drawImage(this.video, 0, 0, targetW, targetH); const imageData = this.offCtx.getImageData(0, 0, targetW, targetH); const rgba = imageData.data; // Convert RGBA → RGB const pixels = targetW * targetH; const rgb = new Uint8Array(pixels * 3); let brightnessSum = 0; let motionSum = 0; for (let i = 0; i < pixels; i++) { const r = rgba[i * 4]; const g = rgba[i * 4 + 1]; const b = rgba[i * 4 + 2]; rgb[i * 3] = r; rgb[i * 3 + 1] = g; rgb[i * 3 + 2] = b; // Luminance for brightness const lum = 0.299 * r + 0.587 * g + 0.114 * b; brightnessSum += lum; // Motion: diff from previous frame if (this.prevFrame) { const pr = this.prevFrame[i * 3]; const pg = this.prevFrame[i * 3 + 1]; const pb = this.prevFrame[i * 3 + 2]; motionSum += Math.abs(r - pr) + Math.abs(g - pg) + Math.abs(b - pb); } } this.brightnessScore = brightnessSum / (pixels * 255); this.motionScore = this.prevFrame ? Math.min(1, motionSum / (pixels * 100)) : 0; this.prevFrame = new Uint8Array(rgb); return { rgb, width: targetW, height: targetH, motion: this.motionScore, brightness: this.brightnessScore }; } /** * Capture full-resolution RGBA for overlay rendering * @returns {ImageData|null} */ captureFullFrame() { if (!this.isActive) return null; this.offscreen.width = this.width; this.offscreen.height = this.height; this.offCtx.drawImage(this.video, 0, 0); return this.offCtx.getImageData(0, 0, this.width, this.height); } /** * Simple body detection from motion differencing. * Returns approximate bounding box of moving region. * @returns {{ x, y, w, h, detected: boolean }} */ detectMotionRegion(targetW = 56, targetH = 56) { if (!this.isActive || !this.prevFrame) return { detected: false }; this.offscreen.width = targetW; this.offscreen.height = targetH; this.offCtx.drawImage(this.video, 0, 0, targetW, targetH); const rgba = this.offCtx.getImageData(0, 0, targetW, targetH).data; let minX = targetW, minY = targetH, maxX = 0, maxY = 0; let motionPixels = 0; const threshold = 25; for (let y = 0; y < targetH; y++) { for (let x = 0; x < targetW; x++) { const i = y * targetW + x; const r = rgba[i * 4], g = rgba[i * 4 + 1], b = rgba[i * 4 + 2]; const pr = this.prevFrame[i * 3], pg = this.prevFrame[i * 3 + 1], pb = this.prevFrame[i * 3 + 2]; const diff = Math.abs(r - pr) + Math.abs(g - pg) + Math.abs(b - pb); if (diff > threshold * 3) { motionPixels++; if (x < minX) minX = x; if (y < minY) minY = y; if (x > maxX) maxX = x; if (y > maxY) maxY = y; } } } const detected = motionPixels > (targetW * targetH * 0.02); return { detected, x: minX / targetW, y: minY / targetH, w: (maxX - minX) / targetW, h: (maxY - minY) / targetH, coverage: motionPixels / (targetW * targetH) }; } }