diff --git a/index.html b/index.html index 27b9e20..ba2bf1e 100644 --- a/index.html +++ b/index.html @@ -17,6 +17,15 @@ transform: scaleX(-1); } + #debugCanvas { + position: absolute; + top: 0; + left: 0; + /* Mirror horizontally for a natural camera preview */ + transform: scaleX(-1); + pointer-events: none; + } + #camera { /* Mirror horizontally for a natural camera preview */ transform: scaleX(-1); @@ -34,6 +43,12 @@ display: block; } + #debugToggle { + margin: 10px auto; + width: 120px; + display: block; + } + #debug { width: 640px; margin: 0 auto; @@ -48,13 +63,15 @@ - - + +
+
+
Turn your face towards the camera and move your head to adjust the glasses position.
@@ -63,6 +80,7 @@ const startButton = document.getElementById('start'); startButton.style.display = 'none'; + const debugToggleButton = document.getElementById('debugToggle'); const debugDiv = document.getElementById('debug'); const object = { @@ -75,6 +93,7 @@ let tryOn = null; let debugInterval = null; + let debugMode = false; function safeFixed(val, digits = 1) { return (typeof val === 'number' && isFinite(val)) ? val.toFixed(digits) : 'N/A'; @@ -128,6 +147,18 @@ } }); + debugToggleButton.addEventListener('click', function() { + if (!tryOn) return; + debugMode = !debugMode; + if (debugMode) { + tryOn.enableDebug(); + debugToggleButton.textContent = 'Hide Debug'; + } else { + tryOn.disableDebug(); + debugToggleButton.textContent = 'Show Debug'; + } + }); + updateDebug(); }); diff --git a/js/tryon-face.js b/js/tryon-face.js index 5205549..ec371e0 100644 --- a/js/tryon-face.js +++ b/js/tryon-face.js @@ -46,12 +46,18 @@ export class TryOnFace { document.getElementById(this.selector).style.width = this.width + "px"; this.video.setAttribute('width', this.width); this.video.setAttribute('height', this.height); - this.tracker = new clm.tracker({useWebGL: true}); - this.tracker.init(); this.stream = null; this.position = { x: 0, y: 0, z: 0 }; this.rotation = { x: 0, y: 0 }; this.size = { x: 1, y: 1, z: 1 }; + this.faceMesh = null; + this.camera = null; + this.debugEnabled = params.debug !== undefined ? params.debug : false; + this.debugCanvas = document.getElementById('debugCanvas'); + this.debugCanvas.width = this.width; + this.debugCanvas.height = this.height; + this.debugCtx = this.debugCanvas.getContext('2d'); + this.lastLandmarks = null; this.init3D(); } @@ -61,86 +67,118 @@ export class TryOnFace { } start() { - const video = this.video; - const constraints = { - video: { - width: { ideal: this.width }, - height: { ideal: this.height } - }, - audio: false - }; - getCameraStreamProxy(constraints) - .then((stream) => { - this.stream = stream; - attachStreamToVideoProxy(stream, video); - video.play(); - this.changeStatus('STATUS_CAMERA_STARTED'); - this.tracker.start(video); - this.loop(); - }) - .catch((err) => { - this.changeStatus('STATUS_CAMERA_ERROR'); - console.error('Camera access error:', err && err.message ? err.message : err); - }); + this.changeStatus('STATUS_SEARCH'); + this.initFaceMesh(); } stop() { - try { - this.tracker.stop(); - } catch (e) {} - if (this.stream) { - stopCameraStreamProxy(this.stream); - this.stream = null; + if (this.camera) { + this.camera.stop(); + this.camera = null; } this.changeStatus('STATUS_READY'); } - calculateDistanceScale(positions) { - const L = CONFIG.LANDMARKS; - const faceWidth = Math.abs(positions[L.RIGHT_EAR][0] - positions[L.LEFT_EAR][0]); - return CONFIG.DETECTION.REFERENCE_FACE_WIDTH / faceWidth; + initFaceMesh() { + this.faceMesh = new window.FaceMesh({ + locateFile: (file) => `https://cdn.jsdelivr.net/npm/@mediapipe/face_mesh/${file}` + }); + this.faceMesh.setOptions({ + maxNumFaces: 1, + refineLandmarks: true, + minDetectionConfidence: 0.5, + minTrackingConfidence: 0.5 + }); + this.faceMesh.onResults(this.onResults.bind(this)); + this.camera = new window.Camera(this.video, { + onFrame: async () => { + await this.faceMesh.send({image: this.video}); + }, + width: this.width, + height: this.height + }); + this.camera.start(); } - calculateYawAngle(positions) { - const L = CONFIG.LANDMARKS; + onResults(results) { + if (!results.multiFaceLandmarks || results.multiFaceLandmarks.length === 0) { + this.changeStatus('STATUS_SEARCH'); + this.size.x = 0; + this.size.y = 0; + this.lastLandmarks = null; + this.render(); + if (this.debugEnabled && this.debugCtx) { + // Clear debug canvas when no face detected + this.debugCtx.clearRect(0, 0, this.width, this.height); + } + return; + } + this.changeStatus('STATUS_FOUND'); + const landmarks = results.multiFaceLandmarks[0]; + this.lastLandmarks = landmarks; + + // Use MediaPipe landmark indices for left/right ear, eyes, nose, etc. + // See: https://github.com/tensorflow/tfjs-models/blob/master/face-landmarks-detection/mesh_map.jpg + // Example indices: + // Left ear: 234, Right ear: 454, Left eye: 33, Right eye: 263, Nose tip: 1, Nose bridge: 168 + const L = { + LEFT_EAR: 234, + RIGHT_EAR: 454, + LEFT_EYE: 33, + RIGHT_EYE: 263, + NOSE_TIP: 1, + NOSE_BRIDGE: 168 + }; + function getXY(idx) { + return [landmarks[idx].x * this.width, landmarks[idx].y * this.height]; + } + const positions = {}; + Object.keys(L).forEach(key => { + positions[L[key]] = getXY.call(this, L[key]); + }); + // Calculate parameters using MediaPipe landmarks + const faceWidth = Math.abs(positions[L.RIGHT_EAR][0] - positions[L.LEFT_EAR][0]); + const distanceScale = CONFIG.DETECTION.REFERENCE_FACE_WIDTH / faceWidth; const faceCenterX = (positions[L.LEFT_EAR][0] + positions[L.RIGHT_EAR][0]) / 2; const noseX = positions[L.NOSE_TIP][0]; const horizontalOffset = noseX - faceCenterX; - const faceWidth = positions[L.RIGHT_EAR][0] - positions[L.LEFT_EAR][0]; const normalizedOffset = horizontalOffset / (faceWidth / 2); const maxRotationRad = Math.PI / 4; - return normalizedOffset * maxRotationRad; - } - - calculateRollAngle(positions) { - const L = CONFIG.LANDMARKS; + const yawAngleRad = normalizedOffset * maxRotationRad; const leftEyeX = positions[L.LEFT_EYE][0]; const leftEyeY = positions[L.LEFT_EYE][1]; const rightEyeX = positions[L.RIGHT_EYE][0]; const rightEyeY = positions[L.RIGHT_EYE][1]; const eyeDeltaX = rightEyeX - leftEyeX; const eyeDeltaY = rightEyeY - leftEyeY; - return Math.atan2(-eyeDeltaY, eyeDeltaX); - } - - calculateGlassesCenter(positions) { - const L = CONFIG.LANDMARKS; + const rollAngleRad = Math.atan2(-eyeDeltaY, eyeDeltaX); const centerX = positions[L.NOSE_TIP][0]; const weight = CONFIG.GLASSES.BRIDGE_WEIGHT; const centerY = positions[L.NOSE_BRIDGE][1] * weight + positions[L.NOSE_TIP][1] * (1 - weight); - return { x: centerX, y: centerY }; - } - - calculateGlassesWidth(positions) { - const L = CONFIG.LANDMARKS; - const faceWidth = positions[L.RIGHT_EAR][0] - positions[L.LEFT_EAR][0]; + const center = this.correct(centerX, centerY); + const eyeDistance = rightEyeX - leftEyeX; const widthByFace = faceWidth * CONFIG.GLASSES.WIDTH_TO_FACE_RATIO; - const eyeDistance = positions[L.RIGHT_EYE][0] - positions[L.LEFT_EYE][0]; const widthByEyes = eyeDistance * CONFIG.GLASSES.WIDTH_TO_EYE_RATIO; - if (!isFinite(eyeDistance) || eyeDistance < 8) { - return widthByFace; + const glassesWidth = (!isFinite(eyeDistance) || eyeDistance < 8) + ? widthByFace + : widthByEyes * 0.65 + widthByFace * 0.35; + let frontWidth = 100, frontHeight = 50; + if (this.textures && this.textures['front'] && this.textures['front'].image) { + frontWidth = this.textures['front'].image.width; + frontHeight = this.textures['front'].image.height; } - return widthByEyes * 0.65 + widthByFace * 0.35; + this.position.x = center.x; + this.position.y = center.y; + this.rotation.y = yawAngleRad * CONFIG.GLASSES.ROTATION_DAMPENING; + this.rotation.z = rollAngleRad * CONFIG.GLASSES.ROTATION_DAMPENING; + this.size.x = glassesWidth; + this.size.y = (this.size.x / frontWidth) * frontHeight; + this.size.z = this.size.x * CONFIG.GLASSES.DEPTH_TO_WIDTH_RATIO; + const absYaw = Math.min(Math.abs(yawAngleRad), maxRotationRad) / maxRotationRad; + const depthDampen = 1 - (absYaw * 0.6); + this.position.z = - (this.size.z) * depthDampen; + this.render(); + this.drawDebugLandmarks(); } loop() { @@ -276,6 +314,7 @@ export class TryOnFace { this.textures = await this.loadTextures(textureLoader, renderer, sources); const materials = this.createMaterials(this.textures); const { scene, camera, cube } = this.createScene(renderer, materials); + this.renderer = renderer; this.render = () => { cube.position.x = this.position.x; cube.position.y = this.position.y; @@ -300,4 +339,146 @@ export class TryOnFace { size: { ...this.size } }; } + + enableDebug() { + this.debugEnabled = true; + } + + disableDebug() { + this.debugEnabled = false; + if (this.debugCtx) { + this.debugCtx.clearRect(0, 0, this.width, this.height); + } + } + + drawDebugLandmarks() { + if (!this.debugEnabled || !this.lastLandmarks || !this.debugCtx) { + return; + } + + const ctx = this.debugCtx; + const landmarks = this.lastLandmarks; + + // Clear previous debug drawings + ctx.clearRect(0, 0, this.width, this.height); + + // Key landmark indices for MediaPipe Face Mesh + const keyPoints = { + LEFT_EAR: 234, + RIGHT_EAR: 454, + LEFT_EYE: 33, + RIGHT_EYE: 263, + NOSE_TIP: 1, + NOSE_BRIDGE: 168, + LEFT_EYE_OUTER: 133, + RIGHT_EYE_OUTER: 362, + LEFT_EYE_INNER: 33, + RIGHT_EYE_INNER: 263, + CHIN: 152, + FOREHEAD: 10, + LEFT_CHEEK: 234, + RIGHT_CHEEK: 454 + }; + + // Draw all landmarks as small dots (optional, can be overwhelming) + ctx.fillStyle = 'rgba(0, 255, 0, 0.3)'; + landmarks.forEach((landmark, idx) => { + const x = landmark.x * this.width; + const y = landmark.y * this.height; + ctx.beginPath(); + ctx.arc(x, y, 1, 0, 2 * Math.PI); + ctx.fill(); + }); + + // Draw key landmarks with labels + Object.entries(keyPoints).forEach(([label, idx]) => { + const landmark = landmarks[idx]; + if (!landmark) return; + + const x = landmark.x * this.width; + const y = landmark.y * this.height; + + // Draw a larger circle for key points + ctx.fillStyle = 'rgba(255, 0, 0, 0.8)'; + ctx.beginPath(); + ctx.arc(x, y, 4, 0, 2 * Math.PI); + ctx.fill(); + + // Draw label + ctx.fillStyle = 'rgba(255, 255, 0, 0.9)'; + ctx.font = '10px monospace'; + ctx.fillText(label, x + 6, y + 4); + }); + + // Draw face bounding box + const leftEar = landmarks[keyPoints.LEFT_EAR]; + const rightEar = landmarks[keyPoints.RIGHT_EAR]; + const forehead = landmarks[keyPoints.FOREHEAD]; + const chin = landmarks[keyPoints.CHIN]; + + if (leftEar && rightEar && forehead && chin) { + const left = leftEar.x * this.width; + const right = rightEar.x * this.width; + const top = forehead.y * this.height; + const bottom = chin.y * this.height; + + ctx.strokeStyle = 'rgba(0, 255, 255, 0.8)'; + ctx.lineWidth = 2; + ctx.strokeRect(left, top, right - left, bottom - top); + + // Draw face width line + const earY = (leftEar.y + rightEar.y) / 2 * this.height; + ctx.strokeStyle = 'rgba(255, 0, 255, 0.8)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(left, earY); + ctx.lineTo(right, earY); + ctx.stroke(); + + // Draw face width measurement + const faceWidth = Math.abs(right - left); + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.font = '12px monospace'; + ctx.fillText(`Face Width: ${faceWidth.toFixed(1)}px`, left, earY - 10); + } + + // Draw eye line + const leftEye = landmarks[keyPoints.LEFT_EYE]; + const rightEye = landmarks[keyPoints.RIGHT_EYE]; + if (leftEye && rightEye) { + const lx = leftEye.x * this.width; + const ly = leftEye.y * this.height; + const rx = rightEye.x * this.width; + const ry = rightEye.y * this.height; + + ctx.strokeStyle = 'rgba(0, 255, 255, 0.8)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(lx, ly); + ctx.lineTo(rx, ry); + ctx.stroke(); + + const eyeDistance = Math.sqrt((rx - lx) ** 2 + (ry - ly) ** 2); + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.font = '12px monospace'; + ctx.fillText(`Eye Distance: ${eyeDistance.toFixed(1)}px`, (lx + rx) / 2, (ly + ry) / 2 - 10); + } + + // Draw nose bridge to tip line + const noseBridge = landmarks[keyPoints.NOSE_BRIDGE]; + const noseTip = landmarks[keyPoints.NOSE_TIP]; + if (noseBridge && noseTip) { + const bx = noseBridge.x * this.width; + const by = noseBridge.y * this.height; + const tx = noseTip.x * this.width; + const ty = noseTip.y * this.height; + + ctx.strokeStyle = 'rgba(255, 128, 0, 0.8)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(bx, by); + ctx.lineTo(tx, ty); + ctx.stroke(); + } + } }