UNPKG

homography-transform

Version:

A robust TypeScript implementation of homography-based transformation between 2D planes, ideal for computer vision and image mapping

579 lines (519 loc) 24.4 kB
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Homography Transform Test</title> <script src="https://cdnjs.cloudflare.com/ajax/libs/numeric/1.2.6/numeric.min.js"></script> <style> body { font-family: Arial, sans-serif; margin: 20px; background-color: #f0f0f0; } .container { display: flex; gap: 20px; margin-bottom: 20px; } .canvas-wrapper { background: white; padding: 10px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); position: relative; } .transformed-coords { margin-top: 10px; padding: 10px; background: white; border-radius: 4px; font-family: monospace; font-size: 12px; color: #28a745; } canvas { border: 1px solid #ccc; margin: 5px; max-width: 100%; height: auto; } #sourceCanvas { width: 800px; /* Fixed display width */ } .controls { margin: 20px 0; padding: 10px; background: white; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); } .resolution-controls { margin: 10px 0; display: flex; align-items: center; gap: 10px; } .resolution-info { font-size: 12px; color: #666; margin-left: 10px; } .resolution-controls input { width: 80px; padding: 4px; border: 1px solid #ccc; border-radius: 4px; } button { padding: 8px 16px; margin: 0 8px; border: none; border-radius: 4px; background-color: #007bff; color: white; cursor: pointer; } button:hover { background-color: #0056b3; } .status { margin-top: 10px; font-style: italic; color: #666; } .coordinates { position: absolute; bottom: -25px; left: 10px; font-size: 12px; color: #666; } .point-label { position: absolute; background: rgba(255, 255, 255, 0.8); padding: 2px 4px; border-radius: 3px; font-size: 10px; color: #007bff; pointer-events: none; } .moveable { cursor: move; } </style> </head> <body> <h1>Homography Transform Test</h1> <div class="controls"> <div class="resolution-controls"> <label>Mobile Resolution:</label> <input type="number" id="mobileWidth" value="2340" placeholder="Width"> <span>×</span> <input type="number" id="mobileHeight" value="1080" placeholder="Height"> <button id="applyResolution">Apply</button> <span class="resolution-info">(Display scaled to 800px width)</span> </div> <button id="resetSourceBtn">Reset Source Points</button> <button id="resetTargetBtn">Reset Target Points</button> <button id="transformBtn" disabled>Transform</button> <button id="clearBtn">Clear All</button> <div class="status" id="status">Select at least 4 points on each canvas</div> </div> <div class="container"> <div class="canvas-wrapper"> <h3>Mobile Screen (Samsung Galaxy S22 Ultra - Landscape)</h3> <canvas id="sourceCanvas" width="2340" height="1080"></canvas> <div id="sourceCoords" class="coordinates"></div> </div> <div class="canvas-wrapper"> <h3>Webcam View (640×480)</h3> <canvas id="targetCanvas" width="640" height="480"></canvas> <div id="targetCoords" class="coordinates"></div> <div id="transformedCoords" class="transformed-coords">Transformed coordinates will appear here</div> </div> </div> <script type="module"> import { Plane, PlaneTransformer } from './bundle.js'; const sourceCanvas = document.getElementById('sourceCanvas'); const targetCanvas = document.getElementById('targetCanvas'); const resetSourceBtn = document.getElementById('resetSourceBtn'); const resetTargetBtn = document.getElementById('resetTargetBtn'); const transformBtn = document.getElementById('transformBtn'); const clearBtn = document.getElementById('clearBtn'); const statusDiv = document.getElementById('status'); const sourceCoordsDiv = document.getElementById('sourceCoords'); const targetCoordsDiv = document.getElementById('targetCoords'); const mobileWidthInput = document.getElementById('mobileWidth'); const mobileHeightInput = document.getElementById('mobileHeight'); const applyResolutionBtn = document.getElementById('applyResolution'); const sourcePoints = []; const targetPoints = []; let currentPhase = 'source'; // 'source' or 'target' let displayScale = 1; let isDragging = false; let dragPoint = null; let dragPointIndex = -1; let dragCanvas = null; const DRAG_THRESHOLD = 10; // pixels let transformedOutline = null; // Store the transformed outline function updateScale() { const displayWidth = 800; // Fixed display width displayScale = sourceCanvas.width / displayWidth; sourceCanvas.style.width = displayWidth + 'px'; sourceCanvas.style.height = (sourceCanvas.height / displayScale) + 'px'; } function getScaledCoordinates(e, canvas) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; return { x: Math.round((e.clientX - rect.left) * scaleX), y: Math.round((e.clientY - rect.top) * scaleY) }; } function drawPoint(ctx, x, y, index, color = '#007bff') { const scale = ctx.canvas === sourceCanvas ? 1/displayScale : 1; const radius = 5/scale; // Draw point ctx.beginPath(); ctx.arc(x, y, radius, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); // Draw point number ctx.fillStyle = 'white'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = `${10/scale}px Arial`; ctx.fillText((index + 1).toString(), x, y); // Draw coordinates ctx.fillStyle = color; ctx.font = `${12/scale}px Arial`; ctx.textAlign = 'left'; ctx.textBaseline = 'top'; ctx.fillText(`(${Math.round(x)},${Math.round(y)})`, x + 10/scale, y + 10/scale); } function updateCoordinates(e, canvas, coordsDiv) { const coords = getScaledCoordinates(e, canvas); coordsDiv.textContent = `x: ${coords.x}, y: ${coords.y}`; } function drawTransformedOutline() { if (!transformedOutline) return; const targetCtx = targetCanvas.getContext('2d'); targetCtx.beginPath(); transformedOutline.forEach((point, i) => { if (i === 0) { targetCtx.moveTo(point.x, point.y); } else { targetCtx.lineTo(point.x, point.y); } // Draw coordinates for each transformed point targetCtx.fillStyle = '#28a745'; targetCtx.font = '12px Arial'; targetCtx.textAlign = 'left'; targetCtx.textBaseline = 'top'; targetCtx.fillText(`(${Math.round(point.x)},${Math.round(point.y)})`, point.x + 5, point.y + 5); }); targetCtx.closePath(); targetCtx.strokeStyle = '#28a745'; targetCtx.lineWidth = 2; targetCtx.stroke(); } function redrawCanvas() { const sourceCtx = sourceCanvas.getContext('2d'); const targetCtx = targetCanvas.getContext('2d'); // Clear canvases sourceCtx.clearRect(0, 0, sourceCanvas.width, sourceCanvas.height); targetCtx.clearRect(0, 0, targetCanvas.width, targetCanvas.height); // Draw grid lines function drawGrid(ctx, width, height) { const scale = ctx.canvas === sourceCanvas ? 1/displayScale : 1; ctx.strokeStyle = '#eee'; ctx.lineWidth = 1; // Draw vertical lines for (let x = 50; x < width; x += 50) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, height); ctx.stroke(); ctx.fillStyle = '#999'; ctx.font = `${10/scale}px Arial`; ctx.fillText(x.toString(), x, height - 5/scale); } // Draw horizontal lines for (let y = 50; y < height; y += 50) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(width, y); ctx.stroke(); ctx.fillStyle = '#999'; ctx.font = `${10/scale}px Arial`; ctx.fillText(y.toString(), 5/scale, y); } } drawGrid(sourceCtx, sourceCanvas.width, sourceCanvas.height); drawGrid(targetCtx, targetCanvas.width, targetCanvas.height); // Draw points sourcePoints.forEach((p, i) => drawPoint(sourceCtx, p.x, p.y, i)); targetPoints.forEach((p, i) => drawPoint(targetCtx, p.x, p.y, i)); // Draw lines between points function drawLines(ctx, points) { if (points.length > 1) { ctx.beginPath(); ctx.moveTo(points[0].x, points[0].y); points.forEach((p, i) => { if (i > 0) ctx.lineTo(p.x, p.y); }); ctx.strokeStyle = '#007bff44'; ctx.lineWidth = 1; ctx.stroke(); } } drawLines(sourceCtx, sourcePoints); drawLines(targetCtx, targetPoints); // Draw transformed outline if it exists drawTransformedOutline(); } function updateStatus() { const minPoints = 4; const sourceCount = sourcePoints.length; const targetCount = targetPoints.length; if (sourceCount < minPoints) { statusDiv.textContent = `Select ${minPoints - sourceCount} more points on source canvas`; currentPhase = 'source'; } else if (targetCount < minPoints) { statusDiv.textContent = `Select ${minPoints - targetCount} more points on target canvas`; currentPhase = 'target'; } else if (sourceCount === targetCount) { statusDiv.textContent = 'Ready to transform! Click Transform button or move points to update'; transformBtn.disabled = false; // Auto-transform when we have enough points transformPlanes(); } redrawCanvas(); } function isNearPoint(x, y, point, canvas) { const rect = canvas.getBoundingClientRect(); const scaleX = canvas.width / rect.width; const scaleY = canvas.height / rect.height; // Convert point coordinates to display coordinates const displayX = point.x / scaleX; const displayY = point.y / scaleY; // Convert mouse coordinates to display coordinates const mouseDisplayX = x / scaleX; const mouseDisplayY = y / scaleY; // Calculate distance in display coordinates const dx = mouseDisplayX - displayX; const dy = mouseDisplayY - displayY; const distance = Math.sqrt(dx * dx + dy * dy); console.log('Canvas:', canvas.id); console.log('Scale:', { x: scaleX, y: scaleY }); console.log('Mouse display:', {x: mouseDisplayX, y: mouseDisplayY}); console.log('Point display:', {x: displayX, y: displayY}); console.log('Distance (display pixels):', distance); return distance < DRAG_THRESHOLD; } function findNearestPoint(x, y, canvas) { const points = canvas === sourceCanvas ? sourcePoints : targetPoints; for (let i = 0; i < points.length; i++) { if (isNearPoint(x, y, points[i], canvas)) { console.log('Found nearest point:', i); return { point: points[i], index: i }; } } return null; } function handleMouseMove(e, canvas) { const coords = getScaledCoordinates(e, canvas); const coordsDiv = canvas === sourceCanvas ? sourceCoordsDiv : targetCoordsDiv; if (isDragging && dragCanvas === canvas) { console.log('Dragging point:', dragPointIndex, 'New position:', coords); console.log('Canvas:', canvas.id); // Update point position while dragging dragPoint.x = Math.round(coords.x); dragPoint.y = Math.round(coords.y); // If we have enough points, transform while dragging if (sourcePoints.length >= 4 && sourcePoints.length === targetPoints.length) { transformPlanes(); } else { redrawCanvas(); } } else { // Check if mouse is near any point const nearest = findNearestPoint(coords.x, coords.y, canvas); canvas.style.cursor = nearest ? 'move' : 'default'; // Highlight the point if mouse is near redrawCanvas(); if (nearest) { console.log('Mouse near point:', nearest.index, 'at', nearest.point, 'on', canvas.id); const ctx = canvas.getContext('2d'); drawPoint(ctx, nearest.point.x, nearest.point.y, nearest.index, '#ff4444'); } } updateCoordinates(e, canvas, coordsDiv); } function handleMouseDown(e, canvas) { const coords = getScaledCoordinates(e, canvas); console.log('Mouse down at:', coords, 'on', canvas.id); const nearest = findNearestPoint(coords.x, coords.y, canvas); if (nearest) { console.log('Starting drag on point:', nearest.index, 'on', canvas.id); isDragging = true; dragPoint = nearest.point; dragPointIndex = nearest.index; dragCanvas = canvas; canvas.style.cursor = 'move'; } } function handleMouseUp(e) { if (isDragging) { console.log('Ending drag of point:', dragPointIndex, 'on', dragCanvas.id); isDragging = false; dragPoint = null; dragPointIndex = -1; dragCanvas = null; sourceCanvas.style.cursor = 'default'; targetCanvas.style.cursor = 'default'; // Check if we have enough points to transform if (sourcePoints.length >= 4 && sourcePoints.length === targetPoints.length) { console.log('Auto-transforming after point move'); transformPlanes(); } } } function transformPlanes() { try { console.log('Starting transformation with points:', 'Source:', JSON.stringify(sourcePoints), 'Target:', JSON.stringify(targetPoints) ); const sourcePlane = new Plane(sourceCanvas.width, sourceCanvas.height); const targetPlane = new Plane(targetCanvas.width, targetCanvas.height); const pointPairs = sourcePoints.map((source, i) => ({ source, target: targetPoints[i] })); const transformer = new PlaneTransformer(sourcePlane, targetPlane, pointPairs); // Store transformed outline points const corners = [ { x: 0, y: 0 }, { x: sourceCanvas.width, y: 0 }, { x: sourceCanvas.width, y: sourceCanvas.height }, { x: 0, y: sourceCanvas.height } ]; transformedOutline = corners.map(corner => transformer.transform(corner)); // Update transformed coordinates display const transformedCoordsDiv = document.getElementById('transformedCoords'); const coordsText = transformedOutline.map((point, i) => { const labels = ['Top-Left', 'Top-Right', 'Bottom-Right', 'Bottom-Left']; return `${labels[i]}: (${Math.round(point.x)}, ${Math.round(point.y)})`; }).join(' | '); transformedCoordsDiv.textContent = coordsText; // Draw the outline redrawCanvas(); const error = transformer.getTransformationError(); console.log('Transformation complete, error:', error); statusDiv.textContent = `Transformation complete! Average error: ${error.toFixed(2)} pixels`; } catch (error) { console.error('Transformation error:', error); statusDiv.textContent = `Error: ${error.message}`; transformedOutline = null; document.getElementById('transformedCoords').textContent = 'Transformation failed'; } } function initializeSourcePoints() { console.log('Initializing source points...'); console.log('Before clear:', sourcePoints.length); sourcePoints.splice(0, sourcePoints.length); // Force clear array console.log('After clear:', sourcePoints.length); const margin = 100; sourcePoints.push( { x: margin, y: margin }, // Top-left { x: sourceCanvas.width - margin, y: margin }, // Top-right { x: sourceCanvas.width - margin, y: sourceCanvas.height - margin }, // Bottom-right { x: margin, y: sourceCanvas.height - margin } // Bottom-left ); console.log('After adding new points:', sourcePoints.length); console.log('Source points:', JSON.stringify(sourcePoints)); updateStatus(); } function initializeTargetPoints() { console.log('Initializing target points...'); console.log('Before clear:', targetPoints.length); targetPoints.splice(0, targetPoints.length); // Force clear array console.log('After clear:', targetPoints.length); const margin = 30; targetPoints.push( { x: margin, y: margin }, // Top-left { x: targetCanvas.width - margin, y: margin }, // Top-right { x: targetCanvas.width - margin, y: targetCanvas.height - margin }, // Bottom-right { x: margin, y: targetCanvas.height - margin } // Bottom-left ); console.log('After adding new points:', targetPoints.length); console.log('Target points:', JSON.stringify(targetPoints)); updateStatus(); } function applyResolution() { const width = parseInt(mobileWidthInput.value) || 2340; const height = parseInt(mobileHeightInput.value) || 1080; sourceCanvas.width = width; sourceCanvas.height = height; updateScale(); sourcePoints.length = 0; initializeSourcePoints(); updateStatus(); } // Update event listeners sourceCanvas.addEventListener('mousemove', e => handleMouseMove(e, sourceCanvas)); targetCanvas.addEventListener('mousemove', e => handleMouseMove(e, targetCanvas)); sourceCanvas.addEventListener('mousedown', e => handleMouseDown(e, sourceCanvas)); targetCanvas.addEventListener('mousedown', e => handleMouseDown(e, targetCanvas)); document.addEventListener('mouseup', handleMouseUp); sourceCanvas.addEventListener('mouseleave', () => { sourceCoordsDiv.textContent = ''; if (!isDragging) sourceCanvas.style.cursor = 'default'; }); targetCanvas.addEventListener('mouseleave', () => { targetCoordsDiv.textContent = ''; if (!isDragging) targetCanvas.style.cursor = 'default'; }); // Update reset buttons document.getElementById('resetSourceBtn').addEventListener('click', () => { console.log('Reset source button clicked'); sourcePoints.splice(0, sourcePoints.length); // Force clear array console.log('Source points cleared, length:', sourcePoints.length); redrawCanvas(); console.log('Canvas redrawn'); initializeSourcePoints(); }); document.getElementById('resetTargetBtn').addEventListener('click', () => { console.log('Reset target button clicked'); targetPoints.splice(0, targetPoints.length); // Force clear array console.log('Target points cleared, length:', targetPoints.length); redrawCanvas(); console.log('Canvas redrawn'); initializeTargetPoints(); }); clearBtn.addEventListener('click', () => { console.log('Clear all button clicked'); sourcePoints.splice(0, sourcePoints.length); // Force clear array targetPoints.splice(0, targetPoints.length); // Force clear array transformedOutline = null; // Clear the transformed outline console.log('Points cleared - Source:', sourcePoints.length, 'Target:', targetPoints.length); currentPhase = 'source'; transformBtn.disabled = true; redrawCanvas(); console.log('Canvas redrawn'); updateStatus(); }); transformBtn.addEventListener('click', transformPlanes); // Add resolution change handler applyResolutionBtn.addEventListener('click', applyResolution); // Initialize scaling and points updateScale(); initializeSourcePoints(); initializeTargetPoints(); updateStatus(); </script> </body> </html>